Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb96fb5d08 | ||
|
|
c820ba9f12 | ||
|
|
e26c736f2e | ||
|
|
a3471a2f3c | ||
|
|
cdf41df01c | ||
|
|
c62fe4ba1e | ||
|
|
7b6ce01d05 | ||
|
|
c4c763fa6c | ||
|
|
75b1701d8a | ||
|
|
ea06237846 | ||
|
|
15997af23d | ||
|
|
51abc5b945 | ||
|
|
fda3f4ea9c | ||
|
|
2d65634d6e | ||
|
|
e3834bb071 | ||
|
|
0ba60dd207 | ||
|
|
3c5b929845 | ||
|
|
c6c39b914f | ||
|
|
0bfac952b6 | ||
|
|
7ae3efef60 | ||
|
|
c50741e49c |
No files matched your search
+5
-1
@@ -60,8 +60,12 @@ CS2_START_TIMEOUT_MINUTES=20
|
||||
CS2_LAN_PORTS=27015-27020
|
||||
# CSTV-Port = Spielport + dieser Abstand (z. B. 27015 -> 27115)
|
||||
CS2_TV_PORT_OFFSET=100
|
||||
# Messer-Server für die Seitenwahl (im Admin-Bereich startbar)
|
||||
KNIFE_MAP=de_mirage
|
||||
KNIFE_WARMUP_SECONDS=90
|
||||
KNIFE_MAX_SERVERS=4
|
||||
# Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen
|
||||
CS2_SERVERNAME_PREFIX=MaxLAN
|
||||
CS2_SERVERNAME_PREFIX=Maxlan
|
||||
DOCKER_BIN=docker
|
||||
|
||||
# --- Static-Pool-Adapter (SERVER_ADAPTER=static) ---
|
||||
|
||||
@@ -101,6 +101,15 @@ Alle Prüfungen passieren im Backend. Die Oberfläche blendet nur zusätzlich au
|
||||
- **Server-Zugangsdaten:** RCON-Passwort, Container-Handle und GSLT liegen ausschließlich in der Datenbank bzw. den Umgebungsvariablen des Backends. Die Snapshot-Funktion gibt sie nie aus (per Test abgesichert). Server- und CSTV-Passwort sind als Beitrittsdaten nötig und werden nur an die jeweils berechtigte Rolle geliefert.
|
||||
- **Befehls-Injection:** Teamnamen werden vor der Übergabe an die Serverkonsole von Anführungszeichen, `;` und Steuerzeichen bereinigt. Passwörter sind rein alphanumerisch.
|
||||
|
||||
### Chat im Match
|
||||
|
||||
Auf der Match-Seite gibt es einen Chat mit zwei Reitern über der Chatbox:
|
||||
|
||||
- **Match-Chat:** beide Teams und der Organisator.
|
||||
- **Team-Chat:** nur die Spieler des eigenen Teams, z. B. für Absprachen im Veto. Der Organisator sieht ihn nur, wenn er selbst im Team mitspielt.
|
||||
|
||||
Zuschauer und Gäste sehen keinen Chat. Er gilt das ganze Match über, von der Lobby bis zum Ende. Danach ist er nur noch lesbar. Nachrichten haben höchstens 300 Zeichen, pro Person gehen höchstens 5 Nachrichten in 10 Sekunden. Ein roter Punkt am Reiter zeigt ungelesene Nachrichten.
|
||||
|
||||
### Wiederherstellung nach Neuladen oder Verbindungsabbruch
|
||||
|
||||
Der gesamte Zustand liegt in SQLite. Der Browser speichert pro Match nur seinen Zugangstoken (`localStorage`).
|
||||
@@ -148,13 +157,28 @@ Beim Start wird jeder Ablauf geprüft:
|
||||
- genau eine Entscheidungsmap
|
||||
- Seitenwahl nur direkt nach einem Pick oder der Entscheidungsmap
|
||||
|
||||
Ungültige oder fehlende Abläufe machen nur dieses Format unauswählbar (mit Begründung in der Oberfläche).
|
||||
Passt kein hinterlegter Ablauf zur Map-Anzahl, erzeugt die Anwendung einen (siehe unten). Unauswählbar ist ein Format nur, wenn es zu wenige Maps gibt (mit Begründung in der Oberfläche).
|
||||
|
||||
### Maps im Admin-Bereich wählen
|
||||
|
||||
Unter Einstellungen → **Map-Pools** wählt der Admin pro Modus, welche Maps im Veto stehen (mindestens 2). Die Auswahl steht in der Datenbank und gilt für neue Matches. Laufende Matches behalten ihre Maps und ihren Ablauf. Maps mit `"active": false` in der Konfiguration stehen im Katalog, sind aber erst nach Auswahl aktiv.
|
||||
|
||||
**Ablauf passend zur Map-Anzahl:** Passt ein hinterlegter Ablauf zur Anzahl der gewählten Maps, gilt er. Sonst erzeugt die Anwendung einen Ablauf:
|
||||
|
||||
1. bis zu zwei Bans (X, dann Y),
|
||||
2. die Picks der Serie im Wechsel, die Seite wählt jeweils das andere Team,
|
||||
3. die restlichen Bans im Wechsel,
|
||||
4. die letzte Map ist die Entscheidungsmap, ihre Seite wählt das Team, das nicht zuletzt dran war.
|
||||
|
||||
Jede weitere Map bedeutet also einen Ban mehr. BO3 braucht mindestens 3 Maps, BO5 mindestens 5. Der Admin-Bereich zeigt nach dem Speichern den Ablauf für jedes Format.
|
||||
|
||||
Beispiel Competitive mit allen 10 Maps, BO3: X bannt, Y bannt, X pickt (Y Seite), Y pickt (X Seite), X, Y, X, Y, X bannen → Entscheidungsmap, Y wählt Seite.
|
||||
|
||||
### Mitgelieferte Abläufe
|
||||
|
||||
**Competitive (7 Maps):** Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train
|
||||
**Competitive (Katalog mit 10 Maps):** Ancient, Anubis, Cache, Dust II, Inferno, Mirage, Nuke, Overpass, Train, Vertigo. Alle 10 sind aktiv. Die festen Abläufe unten gelten für die klassischen 7 Maps (Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train), bei jeder anderen Auswahl erzeugt die Anwendung den Ablauf.
|
||||
|
||||
| Format | Ablauf |
|
||||
| Format (7 Maps) | Ablauf |
|
||||
|---|---|
|
||||
| BO1 | X bannt, Y bannt, X bannt, Y bannt, X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
||||
| BO3 | X bannt, Y bannt, X pickt (Y wählt Seite), Y pickt (X wählt Seite), X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
||||
@@ -166,7 +190,7 @@ Ungültige oder fehlende Abläufe machen nur dieses Format unauswählbar (mit Be
|
||||
|---|---|
|
||||
| BO1 | X bannt, Y bannt, X bannt → Entscheidungsmap, Y wählt Seite |
|
||||
| BO3 | X bannt, Y pickt (X Seite), X pickt (Y Seite) → Entscheidungsmap, X wählt Seite |
|
||||
| BO5 | **nicht unterstützt:** Mit 4 Maps sind keine 5 Karten möglich. Mit einem Pool von mindestens 5 Maps und einem `bo5`-Ablauf wird es automatisch auswählbar. |
|
||||
| BO5 | **nicht unterstützt:** Mit 4 Maps sind keine 5 Karten möglich. Mit mindestens 5 Maps wird es automatisch auswählbar. |
|
||||
|
||||
### Regel für Startseiten
|
||||
|
||||
@@ -179,7 +203,7 @@ Die Wahl wird als `mp_teamname_1` (Team mit CT-Start) und `mp_teamname_2` (T-Sta
|
||||
|
||||
- **Map-Bilder** liegen lokal unter `client/public/maps/<map>.webp` (960 px breit, je ca. 50–90 KB). `"image"` in `map-pools.json` verweist darauf. Neue oder fehlende Bilder lädt `npm run images` von [ghostcap-gaming/cs2-map-images](https://github.com/ghostcap-gaming/cs2-map-images), verkleinert sie und trägt den Pfad ein (`npm run images -- --force` lädt alle neu). Die Bilder stammen aus dem Spiel; die Rechte liegen bei Valve. Ohne Bild zeigt die Kachel ein Muster in der Map-Farbe.
|
||||
- **Seiten-Embleme** für CT und T liegen unter `client/public/sides/ct.png` und `client/public/sides/t.png` (quadratisch, z. B. 256×256). Fehlt eine Datei, erscheint ein schlichtes Ersatzsymbol.
|
||||
- **Logo und Farben:** Das MaxLAN-e.V.-Logo liegt freigestellt unter `client/public/brand/maxlan-logo.webp`. Die Farben stehen als Variablen am Anfang von `client/src/styles.css`: Logo-Blau `#344A99` für Team A und Hauptbuttons, Logo-Rot `#A72A1C` für Team B und den Abschluss der Kopfzeile, jeweils aufgehellt für die Lesbarkeit.
|
||||
- **Logo und Farben:** Das Maxlan-e.V.-Logo liegt freigestellt unter `client/public/brand/maxlan-logo.webp`. Die Farben stehen als Variablen am Anfang von `client/src/styles.css`: Logo-Blau `#344A99` für Team A und Hauptbuttons, Logo-Rot `#A72A1C` für Team B und den Abschluss der Kopfzeile, jeweils aufgehellt für die Lesbarkeit.
|
||||
- Bestehende Matches behalten die Map-Liste, mit der sie erstellt wurden. Neue Bilder erscheinen in neu erstellten Matches.
|
||||
|
||||
> Die mitgelieferten Map-Pools entsprechen dem Stand der Entwicklung. Valve ändert den aktiven Map-Pool regelmäßig. Map-IDs bitte gegen die eigene Serverinstallation prüfen. Eine Map, die nicht existiert, führt zu „Konfiguration fehlgeschlagen“ mit Meldung.
|
||||
@@ -254,6 +278,19 @@ Mit `CS2_DOCKER_HOSTS` verteilt die Anwendung Matches auf mehrere Docker-Hosts.
|
||||
|
||||
Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen auf jedem Host frei sein.
|
||||
|
||||
### Messer-Server für die Seitenwahl
|
||||
|
||||
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).
|
||||
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`) und auf der Startseite mit „Verbinden“.
|
||||
- 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.
|
||||
- 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.
|
||||
- **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.
|
||||
- Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
|
||||
|
||||
### Server im Steam-Serverbrowser (LAN-Reiter)
|
||||
|
||||
Der LAN-Reiter des Steam-Serverbrowsers (Steam → Ansicht → Spielserver → LAN) fragt nur die Ports 27015 bis 27020 ab. Deshalb vergibt die App den Spielport zuerst aus `CS2_LAN_PORTS` (Standard `27015-27020`), danach aus dem normalen Portbereich. Der CSTV-Port liegt immer 100 über dem Spielport (Spiel 27015, CSTV 27115; Spiel 27100, CSTV 27200), der Abstand lässt sich mit `CS2_TV_PORT_OFFSET` ändern. Auf jedem Host müssen also auch die Ports 100 über dem Portbereich frei sein. So passen bis zu sechs Server pro Host in den LAN-Reiter. Weitere Server laufen im normalen Portbereich. Sie sind dann nicht im LAN-Reiter sichtbar, Spieler erreichen sie aber über den Verbindungsbefehl.
|
||||
@@ -261,7 +298,7 @@ Der LAN-Reiter des Steam-Serverbrowsers (Steam → Ansicht → Spielserver → L
|
||||
- Pro Host lassen sich die LAN-Ports in `CS2_DOCKER_HOSTS` mit `"lanPorts":"27016-27019"` einschränken, wenn dort schon etwas anderes auf einem dieser Ports läuft. `"lanPorts":""` schaltet es für einen Host ab. Im Admin-Bereich steht das in der Spalte „LAN-Ports (Steam)“.
|
||||
- Ist ein Port trotzdem belegt, versucht der Start automatisch die nächsten freien Ports.
|
||||
- Server auf LAN-Ports laufen im Netz des Hosts (`--network host`), weil die Port-Weiterleitung von Docker die Suchanfragen (Broadcasts) des Serverbrowsers nicht weitergibt. Die Firewall des Hosts muss die Ports deshalb selbst erlauben.
|
||||
- Der Servername im Browser ist `MaxLAN | Team A vs. Team B`. Den Anfang ändert `CS2_SERVERNAME_PREFIX`.
|
||||
- Der Servername im Browser ist `Maxlan | Team A vs. Team B`. Den Anfang ändert `CS2_SERVERNAME_PREFIX`.
|
||||
- Die Server sind mit Passwort geschützt und zeigen ein Schloss. Ohne `CS2_GSLT` laufen sie mit `sv_lan 1` und erscheinen nicht im Internet-Reiter.
|
||||
|
||||
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
|
||||
@@ -426,6 +463,31 @@ Ein `502 Bad Gateway` vom Proxy heißt, dass er die Anwendung unter `proxy_pass`
|
||||
|
||||
---
|
||||
|
||||
## Admin-Bereich
|
||||
|
||||
Unter `/admin` mit dem Passwort aus `ADMIN_PASSWORD` (leer = Admin-Bereich aus). Die Anmeldung gilt 12 Stunden und nur, solange die Anwendung läuft. Nach einem Neustart der Anwendung muss man sich neu anmelden.
|
||||
|
||||
### Reiter „Matches“
|
||||
|
||||
- **Suche und Filter:** Suche nach Team, Spieler oder Match-ID. Filter Alle, Laufend, Fehler (Zahl rot, wenn es Fehler gibt), Abgeschlossen und Archiv. Der Filter bleibt im Browser gespeichert.
|
||||
- **Matchkarte:** Teams, Modus, Format, Status, Live-Stand, Server- und CSTV-Adresse (`Server 192.168.178.45:27015 · CSTV …:27115`), Zeitpunkt der Server-Anforderung, Pausen-Abzeichen. Bei einem Serverfehler steht die Ursache rot in der Karte.
|
||||
- **Aktionen:** Ansehen, Veto starten, Aufwärmphase beenden, Pausieren/Fortsetzen, Nächste Map, Server neu starten / wiederholen, Server stoppen, Match abschließen, Match abbrechen, Archivieren / Aus dem Archiv holen, Löschen. Destruktive Aktionen fragen nach.
|
||||
- **Details:** Einladungslinks und Codes (erneuerbar), Verbindungsbefehle für Spieler und CSTV, Spielerliste mit „Entfernen“, Rollback der laufenden Map.
|
||||
- **Archiv:** abgeschlossene oder abgebrochene Matches lassen sich archivieren. Nach `ARCHIVE_AFTER_DAYS` Tagen (Standard 3, 0 = nie) geschieht das automatisch. Archivierte Matches stehen nur unter dem Filter „Archiv“.
|
||||
|
||||
Startet man den Server eines laufenden Matches neu, lädt die Anwendung danach die aktuelle Map samt Passwort wieder. Ein frisch gestarteter Server hat die volle Leerlauf-Frist, bevor er automatisch heruntergefahren wird.
|
||||
|
||||
### 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.
|
||||
|
||||
- **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“.
|
||||
- **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.
|
||||
- **Image aktualisieren:** lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter.
|
||||
- **Discord-Meldungen:** welche Ereignisse gemeldet werden, plus Testmeldung.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die wichtigsten:
|
||||
@@ -440,6 +502,19 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
||||
| `SERVER_ADAPTER` | `demo` | `demo`, `docker` oder `static` |
|
||||
| `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren |
|
||||
| `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server |
|
||||
| `ADMIN_PASSWORD` | leer | Passwort für den Admin-Bereich `/admin`; leer = Admin-Bereich aus |
|
||||
| `VETO_STEP_SECONDS` | `30` | Zeit pro Veto-Schritt, danach wird automatisch gewählt; `0` = ohne Frist |
|
||||
| `ARCHIVE_AFTER_DAYS` | `3` | abgeschlossene Matches nach so vielen Tagen archivieren; `0` = nie |
|
||||
| `DOCKER_HOST` | leer | Docker-Ziel für einen einzelnen Host, z. B. `ssh://cs2docker` |
|
||||
| `CS2_DOCKER_HOSTS` | leer | mehrere Docker-Hosts als JSON-Liste (siehe „Mehrere Docker-Hosts“) |
|
||||
| `CS2_PORT_RANGE_START`, `CS2_PORT_RANGE_END` | `27015`, `27115` | Bereich der Spielports; CSTV liegt jeweils `CS2_TV_PORT_OFFSET` darüber |
|
||||
| `CS2_LAN_PORTS` | `27015-27020` | Spielports für den LAN-Reiter von Steam, werden zuerst vergeben; leer = aus |
|
||||
| `CS2_TV_PORT_OFFSET` | `100` | Abstand CSTV-Port zum Spielport (27015 → 27115) |
|
||||
| `CS2_SERVERNAME_PREFIX` | `Maxlan` | Anfang des Servernamens im Serverbrowser |
|
||||
| `CS2_START_TIMEOUT_MINUTES` | `20` | Startzeit eines Servers inklusive Download der Spieldateien |
|
||||
| `KNIFE_MAP` | `de_mirage` | Standard-Map für Messer-Server |
|
||||
| `KNIFE_WARMUP_SECONDS` | `90` | Zeit zum Verbinden, bevor die Messerrunde beginnt |
|
||||
| `KNIFE_MAX_SERVERS` | `4` | höchstens so viele Messer-Server |
|
||||
| `CS2_TV_DELAY` | `90` | CSTV-Verzögerung in Sekunden |
|
||||
| `CS2_GSLT` | leer | Docker: Game Server Login Token; leer = LAN-Modus (`sv_lan 1`) |
|
||||
| `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse |
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>CS2 Mapveto – MaxLAN e.V.</title>
|
||||
<title>CS2 Mapveto – Maxlan e.V.</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%23000'/><path d='M8 6l16 20M24 6L8 26' stroke='%23a72a1c' stroke-width='5' stroke-linecap='round'/></svg>" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
+2
-2
@@ -32,8 +32,8 @@ function MainLayout() {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<Link to="/" className="brand" aria-label="MaxLAN e.V. CS2 Mapveto – zur Übersicht">
|
||||
<img src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." className="brand-logo" width="743" height="174" />
|
||||
<Link to="/" className="brand" aria-label="Maxlan e.V. CS2 Mapveto – zur Übersicht">
|
||||
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." className="brand-logo" width="743" height="174" />
|
||||
<span className="brand-app">CS2 Mapveto</span>
|
||||
</Link>
|
||||
{config?.server.simulated && (
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../lib/api.js';
|
||||
|
||||
const time = (ms) => new Date(ms).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
const SEEN_KEY = (matchId) => `cs2mv:chat-seen:${matchId}`;
|
||||
|
||||
function readSeen(matchId) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SEEN_KEY(matchId))) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Chat im Match: Tabs für Match-Chat und Team-Chat über der Nachrichtenliste. */
|
||||
export default function ChatBox({ chat, matchId, token, playerToken }) {
|
||||
const [active, setActive] = useState(chat.channels[0].key);
|
||||
const [text, setText] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [seen, setSeen] = useState(() => readSeen(matchId));
|
||||
const listRef = useRef(null);
|
||||
const channel = chat.channels.some((c) => c.key === active) ? active : chat.channels[0].key;
|
||||
const messages = chat.messages[channel] ?? [];
|
||||
const lastId = (key) => chat.messages[key]?.at(-1)?.id ?? 0;
|
||||
|
||||
// Aktiven Kanal als gelesen merken und ans Ende scrollen, wenn neue Nachrichten kommen
|
||||
useEffect(() => {
|
||||
const id = lastId(channel);
|
||||
if (id && seen[channel] !== id) {
|
||||
const next = { ...seen, [channel]: id };
|
||||
setSeen(next);
|
||||
try {
|
||||
localStorage.setItem(SEEN_KEY(matchId), JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
}
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [channel, messages.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const send = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!text.trim() || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api(`/matches/${matchId}/chat`, { method: 'POST', body: { channel, text }, token, playerToken });
|
||||
setText('');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel chat-box" aria-label="Chat">
|
||||
<div className="chat-tabs" role="tablist">
|
||||
{chat.channels.map((c) => {
|
||||
const unread = c.key !== channel && lastId(c.key) > (seen[c.key] ?? 0);
|
||||
return (
|
||||
<button key={c.key} role="tab" aria-selected={c.key === channel}
|
||||
className={`chat-tab chat-tab-${c.key.toLowerCase()} ${c.key === channel ? 'is-active' : ''}`}
|
||||
onClick={() => setActive(c.key)}>
|
||||
{c.label}
|
||||
{unread && <span className="chat-unread" aria-label="neue Nachrichten" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ol className="chat-list" ref={listRef} aria-live="polite">
|
||||
{messages.length === 0 && (
|
||||
<li className="chat-empty muted small">
|
||||
{channel === 'match' ? 'Noch keine Nachricht. Hier schreiben beide Teams und der Organisator.' : 'Noch keine Nachricht. Diesen Chat sieht nur dein Team.'}
|
||||
</li>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<li key={m.id} className={`chat-msg ${m.mine ? 'is-mine' : ''}`}>
|
||||
<span className="chat-meta">
|
||||
<strong className={m.team ? `team-${m.team.toLowerCase()}` : 'chat-org'}>{m.author}</strong>
|
||||
{m.organizer && m.team && <span className="chat-role">Organisator</span>}
|
||||
<time className="muted">{time(m.at)}</time>
|
||||
</span>
|
||||
<span className="chat-text">{m.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{chat.canWrite ? (
|
||||
<form className="chat-form" onSubmit={send}>
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
maxLength={chat.maxLength}
|
||||
placeholder={channel === 'match' ? 'Nachricht an alle im Match' : 'Nachricht an dein Team'}
|
||||
aria-label="Nachricht"
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={!text.trim() || sending}>Senden</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="small muted chat-closed">Das Match ist beendet, der Chat ist nur noch lesbar.</p>
|
||||
)}
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+422
-19
@@ -3,10 +3,43 @@ 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';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
|
||||
const KEY = 'cs2mv:admin';
|
||||
const FILTER_KEY = 'cs2mv:admin:filter';
|
||||
const REFRESH_MS = 3000;
|
||||
|
||||
const RUNNING = ['lobby', 'veto', 'server_setup', 'live'];
|
||||
const CLOSED = ['finished', 'cancelled'];
|
||||
const SERVER_ERRORS = ['start_failed', 'failed', 'config_failed'];
|
||||
const hasError = (m) => SERVER_ERRORS.includes(m.server?.status);
|
||||
|
||||
/** Filter der Matchliste. "archive" lädt eine eigene Liste vom Server. */
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: 'Alle', test: () => true },
|
||||
{ key: 'running', label: 'Laufend', test: (m) => RUNNING.includes(m.status) },
|
||||
{ key: 'errors', label: 'Fehler', test: hasError },
|
||||
{ key: 'closed', label: 'Abgeschlossen', test: (m) => CLOSED.includes(m.status) },
|
||||
{ key: 'archive', label: 'Archiv', test: () => true },
|
||||
];
|
||||
|
||||
const readFilter = () => {
|
||||
try {
|
||||
const f = localStorage.getItem(FILTER_KEY);
|
||||
return FILTERS.some((x) => x.key === f) ? f : 'all';
|
||||
} catch {
|
||||
return 'all';
|
||||
}
|
||||
};
|
||||
|
||||
/** Suche nach Teamnamen, Spielern und Match-ID, ohne Groß-/Kleinschreibung. */
|
||||
function matchesSearch(m, query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const hay = [m.id, m.teams.A.name, m.teams.B.name, ...['A', 'B'].flatMap((t) => m.teams[t].players.map((p) => p.nickname))];
|
||||
return hay.some((x) => String(x ?? '').toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
const getToken = () => {
|
||||
try {
|
||||
return sessionStorage.getItem(KEY);
|
||||
@@ -26,11 +59,23 @@ const storeToken = (t) => {
|
||||
export default function AdminPage() {
|
||||
const [token, setToken] = useState(getToken);
|
||||
const [matches, setMatches] = useState(null);
|
||||
const [hosts, setHosts] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [notice, setNotice] = useState(null);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [tab, setTab] = useState('matches');
|
||||
const [filter, setFilterState] = useState(readFilter);
|
||||
const [query, setQuery] = useState('');
|
||||
const [archivedCount, setArchivedCount] = useState(0);
|
||||
const setFilter = (f) => {
|
||||
setFilterState(f);
|
||||
// Beim Wechsel zwischen Archiv und normaler Liste neu laden
|
||||
if ((f === 'archive') !== (filter === 'archive')) setMatches(null);
|
||||
try {
|
||||
localStorage.setItem(FILTER_KEY, f);
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
};
|
||||
|
||||
const logout = useCallback(() => {
|
||||
api('/admin/logout', { method: 'POST', token }).catch(() => {});
|
||||
@@ -41,9 +86,9 @@ export default function AdminPage() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api('/admin/matches', { token });
|
||||
const res = await api(`/admin/matches${filter === 'archive' ? '?archived=1' : ''}`, { token });
|
||||
setMatches(res.matches);
|
||||
setHosts(res.hosts ?? []);
|
||||
setArchivedCount(res.archivedCount ?? 0);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (err.status === 401) {
|
||||
@@ -53,7 +98,7 @@ export default function AdminPage() {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
}, [token]);
|
||||
}, [token, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return undefined;
|
||||
@@ -100,20 +145,35 @@ export default function AdminPage() {
|
||||
<button className={`admin-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Einstellungen</button>
|
||||
</nav>
|
||||
|
||||
{tab === 'settings' && token && <ServerSettingsPanel token={token} setNotice={setNotice} />}
|
||||
{tab === 'settings' && token && <DockerHostsPanel token={token} setNotice={setNotice} />}
|
||||
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||
{tab === 'settings' && token && <SettingsLayout token={token} setNotice={setNotice} />}
|
||||
{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' && matches === null && !error && <p className="muted">Lade Matches …</p>}
|
||||
{tab === 'matches' && matches && matches.length === 0 && <p className="muted">Noch keine Matches.</p>}
|
||||
|
||||
{tab === 'matches' && <div className="admin-list">
|
||||
{matches?.map((m) => (
|
||||
{tab === 'matches' && (
|
||||
<MatchToolbar
|
||||
matches={filter === 'archive' ? [] : matches ?? []}
|
||||
archivedCount={archivedCount}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
/>
|
||||
)}
|
||||
{tab === 'matches' && matches && (() => {
|
||||
const active = FILTERS.find((f) => f.key === filter);
|
||||
const shown = matches.filter((m) => active.test(m) && matchesSearch(m, query));
|
||||
if (matches.length === 0) {
|
||||
return <p className="muted">{filter === 'archive' ? 'Das Archiv ist leer.' : 'Noch keine Matches.'}</p>;
|
||||
}
|
||||
if (shown.length === 0) return <p className="muted">Keine Matches für diesen Filter{query ? ` und „${query}“` : ''}.</p>;
|
||||
return (
|
||||
<div className="admin-list">
|
||||
{shown.map((m) => (
|
||||
<MatchAdminCard key={m.id} m={m} run={run} busy={busy} token={token} reload={load} setNotice={setNotice} />
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -313,6 +373,10 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasError(m) && m.server.message && (
|
||||
<p className="admin-error small" role="alert">{m.server.message}</p>
|
||||
)}
|
||||
{m.archivedAt && <span className="admin-badge is-archived">Archiviert {formatDateTime(m.archivedAt)}</span>}
|
||||
{paused && <span className="admin-badge is-paused">Pausiert</span>}
|
||||
</div>
|
||||
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
||||
@@ -320,7 +384,10 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
|
||||
<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.status === 'lobby' && m.vetoStart !== 'knife' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
|
||||
{m.status === 'lobby' && m.vetoStart === 'knife' && ['A', 'B'].map((t) => (
|
||||
<span key={t}>{btn(`${m.teams[t].name} beginnt das Veto`, { path: '/veto/start', body: { firstTeam: t }, confirm: `Messerrunde vorbei: ${m.teams[t].name} beginnt das Veto?`, success: 'Veto gestartet' }, 'btn-primary')}</span>
|
||||
))}
|
||||
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
|
||||
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
|
||||
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
|
||||
@@ -330,6 +397,8 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
{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')}
|
||||
{closed && !m.archivedAt && btn('Archivieren', { path: '/archive', success: 'Match archiviert' })}
|
||||
{m.archivedAt && btn('Aus dem Archiv holen', { path: '/unarchive', success: 'Match ist wieder in der Liste' })}
|
||||
{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>
|
||||
|
||||
@@ -406,6 +475,22 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
/** Auslastung der Docker-Hosts, regelmäßig neu geladen (Einstellungen → Docker-Hosts). */
|
||||
function HostUsageLive({ token }) {
|
||||
const [hosts, setHosts] = useState([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => api('/admin/hosts', { token }).then((res) => alive && setHosts(res.hosts)).catch(() => {});
|
||||
load();
|
||||
const t = setInterval(load, REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [token]);
|
||||
return <HostUsage hosts={hosts} />;
|
||||
}
|
||||
|
||||
/** Auslastung der Docker-Hosts: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */
|
||||
function HostUsage({ hosts }) {
|
||||
if (hosts.length === 0) return null;
|
||||
@@ -461,7 +546,7 @@ const shownDefault = (field) => {
|
||||
};
|
||||
|
||||
/** Gameserver-Einstellungen: Vorgaben aus der .env, im Admin-Bereich änderbar. */
|
||||
function ServerSettingsPanel({ token, setNotice }) {
|
||||
function ServerSettingsPanel({ token, setNotice, groups = SETTING_GROUPS.map((g) => g.key), title = 'Gameserver' }) {
|
||||
const [fields, setFields] = useState(null);
|
||||
const [values, setValues] = useState({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -525,13 +610,13 @@ function ServerSettingsPanel({ token, setNotice }) {
|
||||
|
||||
return (
|
||||
<section className="admin-settings">
|
||||
<h2>Gameserver</h2>
|
||||
<h2>{title}</h2>
|
||||
<p className="small muted">
|
||||
Markierte Werte (live) gehen beim Speichern sofort an alle laufenden Server. Die übrigen gelten für neue Server und Vetos.
|
||||
</p>
|
||||
{SETTING_GROUPS.map((g) => (
|
||||
{SETTING_GROUPS.filter((g) => groups.includes(g.key)).map((g) => (
|
||||
<div key={g.key} className="setting-group">
|
||||
<h3>{g.title}</h3>
|
||||
{groups.length > 1 && <h3>{g.title}</h3>}
|
||||
<div className="setting-grid">
|
||||
{fields.filter((f) => f.group === g.key).map((f) => (
|
||||
<div key={f.key} className="setting-item">
|
||||
@@ -594,7 +679,14 @@ function DockerHostsPanel({ token, setNotice }) {
|
||||
}, [pulling, load]);
|
||||
|
||||
if (!data) return null;
|
||||
if (!data.editable) return null;
|
||||
if (!data.editable) {
|
||||
return (
|
||||
<section className="admin-settings">
|
||||
<h2>Docker-Hosts</h2>
|
||||
<p className="muted">Docker-Hosts und Image-Update gibt es nur mit dem Docker-Adapter (<code>SERVER_ADAPTER=docker</code>).</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const original = JSON.stringify(data.hosts.map((h) => ({ ...EMPTY_HOST, ...h, dockerHost: h.dockerHost ?? '' })));
|
||||
const dirty = JSON.stringify(rows) !== original;
|
||||
@@ -699,3 +791,314 @@ function DockerHostsPanel({ token, setNotice }) {
|
||||
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`;
|
||||
}
|
||||
/** Suche und Filter über der Matchliste, mit Anzahl pro Filter. */
|
||||
function MatchToolbar({ matches, archivedCount, filter, setFilter, query, setQuery }) {
|
||||
const count = (f) => (f.key === 'archive' ? archivedCount : matches.filter(f.test).length);
|
||||
return (
|
||||
<div className="admin-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
className="admin-search"
|
||||
placeholder="Team, Spieler oder Match-ID suchen"
|
||||
aria-label="Matches durchsuchen"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="admin-filters" role="group" aria-label="Matches filtern">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`admin-filter ${filter === f.key ? 'is-active' : ''} ${f.key === 'errors' && count(f) > 0 ? 'has-errors' : ''}`}
|
||||
aria-pressed={filter === f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
{(f.key !== 'archive' || archivedCount > 0) && <span className="admin-filter-count">{count(f)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
const STEP_LABEL = { ban: 'Ban', pick: 'Pick', side: 'Seite' };
|
||||
|
||||
/** Veto-Ablauf als kurze Schrittfolge; X = Team, das das Veto beginnt, Y = das andere Team. */
|
||||
function FlowSteps({ flow }) {
|
||||
return (
|
||||
<ol className="flow-steps">
|
||||
{flow.map((raw, i) => {
|
||||
if (raw === 'decider') return <li key={i} className="flow-decider">Entscheidung</li>;
|
||||
const [type, actor] = raw.split(':');
|
||||
return <li key={i} className={`flow-${type}`}>{STEP_LABEL[type]} {actor === 'X' ? '1' : '2'}</li>;
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Welche Maps gespielt werden, pro Modus. Gilt für neue Matches, laufende behalten ihre Maps. */
|
||||
function MapPoolsPanel({ token, setNotice }) {
|
||||
const [pools, setPools] = useState(null);
|
||||
const [selection, setSelection] = useState({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const apply = (res) => {
|
||||
setPools(res.pools);
|
||||
setSelection(Object.fromEntries(res.pools.map((p) => [p.mode, p.maps.filter((m) => m.active).map((m) => m.id)])));
|
||||
};
|
||||
useEffect(() => {
|
||||
api('/admin/map-pools', { token }).then(apply).catch((err) => setNotice({ tone: 'error', text: err.message }));
|
||||
}, [token, setNotice]);
|
||||
if (!pools) return null;
|
||||
|
||||
const toggle = (mode, id) => {
|
||||
const cur = new Set(selection[mode]);
|
||||
if (cur.has(id)) cur.delete(id);
|
||||
else cur.add(id);
|
||||
setSelection({ ...selection, [mode]: [...cur] });
|
||||
};
|
||||
const save = async (mode) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
apply(await api('/admin/map-pools', { method: 'PUT', body: { mode, maps: selection[mode] }, token }));
|
||||
setNotice({ tone: 'ok', text: 'Maps gespeichert – gilt für neue Matches' });
|
||||
} catch (err) {
|
||||
setNotice({ tone: 'error', text: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-settings">
|
||||
<h2>Map-Pools</h2>
|
||||
<p className="small muted">
|
||||
Welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an: Für jede weitere Map gibt es einen Ban mehr.
|
||||
Laufende Matches behalten ihre Maps. „1“ ist das Team, das das Veto beginnt.
|
||||
</p>
|
||||
{pools.map((p) => {
|
||||
const chosen = new Set(selection[p.mode]);
|
||||
const saved = new Set(p.maps.filter((m) => m.active).map((m) => m.id));
|
||||
const dirty = chosen.size !== saved.size || [...chosen].some((id) => !saved.has(id));
|
||||
return (
|
||||
<div key={p.mode} className="setting-group">
|
||||
<h3>{p.label} <span className="muted small">{chosen.size} von {p.maps.length} Maps</span></h3>
|
||||
<div className="pool-maps">
|
||||
{p.maps.map((m) => (
|
||||
<label key={m.id} className={`pool-map ${chosen.has(m.id) ? 'is-on' : ''}`}>
|
||||
<input type="checkbox" checked={chosen.has(m.id)} onChange={() => toggle(p.mode, m.id)} />
|
||||
<MapImage map={m} className="pool-map-art" />
|
||||
<span className="pool-map-name">{m.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{!dirty && (
|
||||
<div className="pool-flows">
|
||||
{Object.entries(p.formats).map(([f, info]) => (
|
||||
<div key={f} className="pool-flow">
|
||||
<strong>{f.toUpperCase()}</strong>
|
||||
{info.supported ? <FlowSteps flow={info.flow} /> : <span className="muted small">{info.reason}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-actions">
|
||||
<button className="btn btn-sm btn-primary" disabled={busy || !dirty || chosen.size < 2} onClick={() => save(p.mode)}>Speichern</button>
|
||||
{dirty && <button className="btn btn-sm" disabled={busy} onClick={() => setSelection({ ...selection, [p.mode]: [...saved] })}>Verwerfen</button>}
|
||||
{chosen.size < 2 && <span className="small tone-error">Mindestens 2 Maps</span>}
|
||||
{dirty && chosen.size >= 2 && <span className="small muted">Nach dem Speichern steht hier der neue Veto-Ablauf.</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const SETTINGS_KEY = 'cs2mv:admin:settings';
|
||||
|
||||
/** Menüpunkte der Einstellungen, in Gruppen für die Seitenleiste. */
|
||||
const SETTINGS_MENU = [
|
||||
{
|
||||
title: 'Veto',
|
||||
items: [{ key: 'maps', label: 'Map-Pools', hint: 'Maps im Veto' }],
|
||||
},
|
||||
{
|
||||
title: 'Gameserver',
|
||||
items: [
|
||||
{ key: 'match', label: 'Match-Regeln', hint: 'Runden, Verlängerung, Befehle' },
|
||||
{ key: 'tv', label: 'CSTV', hint: 'an/aus, Verzögerung' },
|
||||
{ key: 'times', label: 'Zeiten', hint: 'Veto, Leerlauf, Start, Archiv' },
|
||||
{ key: 'knife', label: 'Messer-Server', hint: 'Seitenwahl, 5v5 und 2v2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Server-Betrieb',
|
||||
items: [{ key: 'hosts', label: 'Docker-Hosts', hint: 'Hosts und Image-Update' }],
|
||||
},
|
||||
{
|
||||
title: 'Benachrichtigungen',
|
||||
items: [{ key: 'discord', label: 'Discord', hint: 'Meldungen und Test' }],
|
||||
},
|
||||
];
|
||||
const SETTINGS_KEYS = SETTINGS_MENU.flatMap((g) => g.items.map((i) => i.key));
|
||||
|
||||
const readSettingsSection = () => {
|
||||
try {
|
||||
const v = localStorage.getItem(SETTINGS_KEY);
|
||||
return SETTINGS_KEYS.includes(v) ? v : SETTINGS_KEYS[0];
|
||||
} catch {
|
||||
return SETTINGS_KEYS[0];
|
||||
}
|
||||
};
|
||||
|
||||
/** Einstellungen mit Seitenleiste: links die Menüpunkte, rechts der gewählte Bereich. */
|
||||
function SettingsLayout({ token, setNotice }) {
|
||||
const [section, setSectionState] = useState(readSettingsSection);
|
||||
const setSection = (key) => {
|
||||
setSectionState(key);
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, key);
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
};
|
||||
const current = SETTINGS_MENU.flatMap((g) => g.items).find((i) => i.key === section);
|
||||
|
||||
return (
|
||||
<div className="settings-layout">
|
||||
<nav className="settings-nav" aria-label="Einstellungen">
|
||||
{SETTINGS_MENU.map((g) => (
|
||||
<div key={g.title} className="settings-nav-group">
|
||||
<div className="settings-nav-title">{g.title}</div>
|
||||
{g.items.map((i) => (
|
||||
<button
|
||||
key={i.key}
|
||||
className={`settings-nav-item ${section === i.key ? 'is-active' : ''}`}
|
||||
aria-current={section === i.key ? 'page' : undefined}
|
||||
onClick={() => setSection(i.key)}
|
||||
>
|
||||
<span>{i.label}</span>
|
||||
<small>{i.hint}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="settings-content">
|
||||
{section === 'maps' && <MapPoolsPanel token={token} setNotice={setNotice} />}
|
||||
{['match', 'tv', 'times'].includes(section) && (
|
||||
<ServerSettingsPanel key={section} token={token} setNotice={setNotice} groups={[section]} title={current.label} />
|
||||
)}
|
||||
{section === 'knife' && <KnifePanel token={token} setNotice={setNotice} />}
|
||||
{section === 'hosts' && <HostUsageLive token={token} />}
|
||||
{section === 'hosts' && <DockerHostsPanel token={token} setNotice={setNotice} />}
|
||||
{section === 'discord' && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +89,7 @@ export default function CreateMatch({ config }) {
|
||||
<option value="coin">Münzwurf beim Veto-Start</option>
|
||||
<option value="A">{form.teamA || 'Team A'}</option>
|
||||
<option value="B">{form.teamB || 'Team B'}</option>
|
||||
<option value="knife">Messerrunde: Organisator legt danach fest, wer beginnt</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import SeriesList from '../components/SeriesList.jsx';
|
||||
import EventLog from '../components/EventLog.jsx';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
import SelfJoin from '../components/SelfJoin.jsx';
|
||||
import ChatBox from '../components/ChatBox.jsx';
|
||||
|
||||
export default function MatchPage() {
|
||||
const { id } = useParams();
|
||||
@@ -137,6 +138,7 @@ export default function MatchPage() {
|
||||
|
||||
{viewer.role !== 'public' && (
|
||||
<div className="match-lower">
|
||||
{state.chat && <ChatBox chat={state.chat} matchId={id} token={token} playerToken={playerToken} />}
|
||||
<ServerPanel {...ctx} />
|
||||
{isOrg && <OrganizerPanel {...ctx} token={token} />}
|
||||
<EventLog events={state.events} />
|
||||
@@ -170,7 +172,21 @@ function Stage(ctx) {
|
||||
{missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{isOrg ? (
|
||||
{state.vetoStart === 'knife' && <KnifeRound state={state} />}
|
||||
{isOrg && state.vetoStart === 'knife' ? (
|
||||
<div className="knife-decide">
|
||||
<p>Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.</p>
|
||||
<div className="row">
|
||||
{['A', 'B'].map((t) => (
|
||||
<button key={t} className={`btn btn-lg knife-team-${t.toLowerCase()}`} disabled={!state.canStartVeto || !!busy}
|
||||
onClick={() => act('veto/start', { firstTeam: t }, `Veto gestartet – ${state.teams[t].name} beginnt`)}>
|
||||
{state.teams[t].name} beginnt
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!state.canStartVeto && <p className="small muted">Die Knöpfe werden aktiv, sobald beide Teams bereit sind.</p>}
|
||||
</div>
|
||||
) : isOrg ? (
|
||||
<>
|
||||
<button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy}
|
||||
onClick={() => act('veto/start', null, 'Veto gestartet')}>
|
||||
@@ -178,6 +194,8 @@ function Stage(ctx) {
|
||||
</button>
|
||||
{!state.canStartVeto && <p className="small muted">Der Knopf wird aktiv, sobald beide Teams bereit sind.</p>}
|
||||
</>
|
||||
) : state.vetoStart === 'knife' ? (
|
||||
<p className="small muted">Nach der Messerrunde legt der Organisator fest, welches Team das Veto beginnt.</p>
|
||||
) : (
|
||||
<p className="small muted">
|
||||
{missing.length === 0
|
||||
@@ -200,6 +218,56 @@ function Stage(ctx) {
|
||||
}
|
||||
|
||||
/** Sagt konkret, was einem Team noch fehlt, damit es als bereit gilt. */
|
||||
/**
|
||||
* Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst.
|
||||
* Frei = niemand drauf (oder Spielerzahl unbekannt).
|
||||
*/
|
||||
function KnifeRound({ state }) {
|
||||
const [servers, setServers] = useState(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => alive && setServers([]));
|
||||
load();
|
||||
const t = setInterval(load, 10_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
const free = (servers ?? []).filter((k) => !k.playerCount);
|
||||
const matching = free.filter((k) => k.mode === state.mode);
|
||||
const shown = matching.length ? matching : free;
|
||||
return (
|
||||
<div className="knife-round">
|
||||
<h3>Seitenwahl per Messerrunde</h3>
|
||||
<p className="small">
|
||||
Beide Teams gehen zusammen auf einen freien Messer-Server und spielen eine Runde nur mit Messer. Danach legt der Organisator fest,
|
||||
welches Team das Veto beginnt.
|
||||
</p>
|
||||
{servers === null ? (
|
||||
<p className="small muted">Suche freie Messer-Server …</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="small tone-pending">Gerade ist kein Messer-Server frei. Fragt die Turnierleitung, oder wartet, bis einer frei wird.</p>
|
||||
) : (
|
||||
<>
|
||||
{!matching.length && <p className="small muted">Kein freier Server mit eurer Teamgröße, hier die anderen freien:</p>}
|
||||
<ul className="knife-list">
|
||||
{shown.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>
|
||||
<code>{k.connect}</code>
|
||||
<a className="btn btn-sm btn-primary" href={k.steamUrl}>Verbinden</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function readinessGap(team, requireFull) {
|
||||
const r = team.readiness;
|
||||
const gaps = [];
|
||||
|
||||
+187
-37
@@ -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';
|
||||
@@ -6,51 +6,145 @@ import { getAccess } from '../lib/session.js';
|
||||
import { useOverview } from '../lib/useMatch.js';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
|
||||
const GROUPS = [
|
||||
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
|
||||
{ key: 'running', title: 'Laufend', empty: 'Gerade läuft kein Match.' },
|
||||
{ key: 'closed', title: 'Abgeschlossen', empty: 'Noch keine abgeschlossenen Matches.' },
|
||||
];
|
||||
|
||||
export default function Overview() {
|
||||
const { matches } = useOverview();
|
||||
const mine = (matches ?? []).filter((m) => m.group !== 'closed' && getAccess(m.id));
|
||||
const step = mine.length ? Math.max(...mine.map((m) => nextStep(m, getAccess(m.id)).step)) : 0;
|
||||
const running = (matches ?? []).filter((m) => m.group === 'running');
|
||||
const open = (matches ?? []).filter((m) => m.group === 'open');
|
||||
const closed = (matches ?? []).filter((m) => m.group === 'closed');
|
||||
|
||||
return (
|
||||
<div className="overview">
|
||||
<section className="overview-head">
|
||||
<div>
|
||||
<h1>Match-Lobbys</h1>
|
||||
<p className="lead">Teams treten mit ihrem Einladungscode bei, die Captains führen das Map-Veto, der Server wird automatisch eingerichtet.</p>
|
||||
<div className="overview home">
|
||||
<section className={`home-hero ${mine.length ? 'has-mine' : ''}`}>
|
||||
<div className="home-intro">
|
||||
<h1>Mit deinem Team ins Match</h1>
|
||||
<p className="lead">
|
||||
Den Teamlink postet der Organisator im Match-Chat auf maxlan.de oder in Discord. Öffne den Link deines Teams.
|
||||
Hast du nur den Code, gib ihn hier ein.
|
||||
</p>
|
||||
<CodeTicket />
|
||||
</div>
|
||||
<CodeEntry />
|
||||
{mine.length > 0 && <MyMatches matches={mine} />}
|
||||
</section>
|
||||
|
||||
<Steps current={step} />
|
||||
|
||||
<KnifeServers />
|
||||
|
||||
{matches === null ? (
|
||||
<p className="muted">Lade Matches …</p>
|
||||
) : (
|
||||
<div className="groups">
|
||||
{GROUPS.map((g) => {
|
||||
const list = matches.filter((m) => m.group === g.key);
|
||||
return (
|
||||
<section key={g.key} className={`group group-${g.key}`}>
|
||||
<h2>{g.title} <span className="count">{list.length}</span></h2>
|
||||
{list.length === 0 ? <p className="muted small">{g.empty}</p> : g.key === 'running' ? (
|
||||
<div className="run-grid">
|
||||
{list.map((m) => <RunningCard key={m.id} match={m} />)}
|
||||
</div>
|
||||
<>
|
||||
<section className="group home-live">
|
||||
<h2>Laufend <span className="count">{running.length}</span></h2>
|
||||
{running.length === 0 ? (
|
||||
<p className="muted small">Gerade läuft kein Match.</p>
|
||||
) : (
|
||||
<ul className="match-list">
|
||||
{list.map((m) => <MatchRow key={m.id} match={m} />)}
|
||||
</ul>
|
||||
<div className="run-grid">
|
||||
{running.map((m) => <RunningCard key={m.id} match={m} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="home-lists">
|
||||
<section className="group">
|
||||
<h2>Offene Lobbys <span className="count">{open.length}</span></h2>
|
||||
{open.length === 0 ? (
|
||||
<p className="muted small">Keine offene Lobby. Sobald der Organisator ein Match erstellt, steht es hier.</p>
|
||||
) : (
|
||||
<ul className="match-list">{open.map((m) => <MatchRow key={m.id} match={m} />)}</ul>
|
||||
)}
|
||||
</section>
|
||||
<section className="group">
|
||||
<h2>Ergebnisse <span className="count">{closed.length}</span></h2>
|
||||
{closed.length === 0 ? (
|
||||
<p className="muted small">Noch kein Match abgeschlossen.</p>
|
||||
) : (
|
||||
<ul className="match-list">{closed.slice(0, 12).map((m) => <MatchRow key={m.id} match={m} />)}</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ title: 'Teamlink öffnen', text: 'Link aus dem Match-Chat, Namen eingeben' },
|
||||
{ title: 'Bereit klicken', text: 'In der Lobby auf „Ich bin bereit“' },
|
||||
{ title: 'Map-Veto', text: 'Die Captains bannen und wählen' },
|
||||
{ title: 'Auf den Server', text: 'Befehl kopieren oder im LAN-Reiter' },
|
||||
];
|
||||
|
||||
/** Was als Nächstes zu tun ist, je nach Stand des Matches und Rolle. step = Schritt in STEPS (1–4). */
|
||||
function nextStep(m, access) {
|
||||
const organizer = access?.kind === 'organizer';
|
||||
switch (m.status) {
|
||||
case 'lobby':
|
||||
return organizer
|
||||
? { step: 2, text: 'Lobby offen: Veto starten, sobald beide Teams bereit sind', action: 'Zur Lobby' }
|
||||
: { step: 2, text: 'Lobby offen: Klick auf „Ich bin bereit“', action: 'Zur Lobby' };
|
||||
case 'veto':
|
||||
return { step: 3, text: 'Das Map-Veto läuft', action: 'Zum Veto' };
|
||||
case 'server_setup':
|
||||
return { step: 4, text: 'Der Server wird eingerichtet, gleich geht es los', action: 'Zum Match' };
|
||||
default:
|
||||
return organizer
|
||||
? { step: 4, text: 'Match läuft: Aufwärmphase beenden, wenn alle da sind', action: 'Zum Match' }
|
||||
: { step: 4, text: 'Match läuft: Verbindung steht auf der Match-Seite', action: 'Zum Server' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches, für die dieser Browser einen Zugang hat, mit dem nächsten Schritt. */
|
||||
function MyMatches({ matches }) {
|
||||
return (
|
||||
<aside className="home-mine" aria-label="Deine Matches">
|
||||
<h2>{matches.length === 1 ? 'Dein Match' : 'Deine Matches'}</h2>
|
||||
<ul>
|
||||
{matches.map((m) => {
|
||||
const access = getAccess(m.id);
|
||||
const next = nextStep(m, access);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<Link to={`/m/${m.id}`} className="mine-card">
|
||||
<span className="mine-teams">
|
||||
<span className="team-a">{m.teamA}</span> <span className="vs">vs.</span> <span className="team-b">{m.teamB}</span>
|
||||
</span>
|
||||
<span className="mine-meta">
|
||||
{m.modeLabel}, {FORMAT_LABEL[m.format]}
|
||||
{access.kind === 'organizer' ? ', du bist Organisator' : access.kind === 'spectator' ? ', als Zuschauer' : ''}
|
||||
</span>
|
||||
<span className="mine-next">{next.text}</span>
|
||||
<span className="btn btn-primary btn-sm mine-go">{next.action}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** Die vier Schritte vom Teamlink bis auf den Server. current = Schritt des eigenen Matches (0 = keins). */
|
||||
function Steps({ current }) {
|
||||
return (
|
||||
<ol className="home-steps">
|
||||
{STEPS.map((st, i) => {
|
||||
const n = i + 1;
|
||||
const state = current === 0 ? '' : n < current ? 'is-done' : n === current ? 'is-current' : '';
|
||||
return (
|
||||
<li key={st.title} className={state} aria-current={n === current ? 'step' : undefined}>
|
||||
<span className="step-n">{n}</span>
|
||||
<span className="step-title">{st.title}</span>
|
||||
<span className="step-text">{st.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchRow({ match: m }) {
|
||||
const access = getAccess(m.id);
|
||||
const server = serverLabel(m.server);
|
||||
@@ -112,28 +206,84 @@ function RunningCard({ match: m }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CodeEntry() {
|
||||
/** Code in die Form ABCD-EFGH bringen (Großbuchstaben, Bindestrich nach vier Zeichen). */
|
||||
function formatCode(raw) {
|
||||
const clean = raw.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
|
||||
return clean.length > 4 ? `${clean.slice(0, 4)}-${clean.slice(4)}` : clean;
|
||||
}
|
||||
|
||||
/** Code-Eingabe im Stil einer Eintrittskarte. */
|
||||
function CodeTicket() {
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const complete = code.replace(/-/g, '').length === 8;
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api('/access', { method: 'POST', body: { code } });
|
||||
navigate(res.access === 'spectator' ? `/watch/${code.trim()}` : `/join/${code.trim()}`);
|
||||
navigate(res.access === 'spectator' ? `/watch/${code}` : `/join/${code}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form className="code-entry" onSubmit={submit}>
|
||||
<label htmlFor="code">Einladungs- oder Zuschauercode</label>
|
||||
<div className="row">
|
||||
<input id="code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="ABCD-EFGH" autoComplete="off" spellCheck="false" />
|
||||
<button className="btn" disabled={code.replace(/[^a-z0-9]/gi, '').length < 8}>Beitreten</button>
|
||||
</div>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
<form className="ticket" onSubmit={submit}>
|
||||
<label className="ticket-stub" htmlFor="code">Code</label>
|
||||
<input
|
||||
id="code"
|
||||
className="ticket-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(formatCode(e.target.value))}
|
||||
placeholder="ABCD-EFGH"
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck="false"
|
||||
inputMode="text"
|
||||
aria-describedby={error ? 'code-error' : 'code-hint'}
|
||||
/>
|
||||
<button className="btn btn-primary ticket-go" disabled={!complete || busy}>{busy ? 'Prüfe …' : 'Beitreten'}</button>
|
||||
{error
|
||||
? <p id="code-error" className="field-error ticket-msg">{error}</p>
|
||||
: <p id="code-hint" className="muted small ticket-msg">Teamcode oder Zuschauercode, acht Zeichen.</p>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export default function StreamOverviewPage() {
|
||||
return (
|
||||
<ScaledStage>
|
||||
<div className="st-message">
|
||||
<img src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
|
||||
<h1>Stream-Übersicht nicht verfügbar</h1>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
@@ -64,7 +64,7 @@ export default function StreamOverviewPage() {
|
||||
<ScaledStage>
|
||||
<div className="so-root">
|
||||
<header className="st-top">
|
||||
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
|
||||
<div className="so-title">
|
||||
<strong>Counter-Strike 2</strong>
|
||||
{liveCount > 0 && <span className="so-count so-count-live">{liveCount} live</span>}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ScaledStage({ children }) {
|
||||
function StreamMessage({ title, text }) {
|
||||
return (
|
||||
<div className="st-message">
|
||||
<img src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
|
||||
<h1>{title}</h1>
|
||||
{text && <p>{text}</p>}
|
||||
</div>
|
||||
@@ -99,7 +99,7 @@ function StreamContent({ matchId, code }) {
|
||||
return (
|
||||
<div className={`st-root phase-${phase} ${phase !== 'lobby' && state.series.length > 0 ? 'has-maps' : 'no-maps'}`}>
|
||||
<header className="st-top">
|
||||
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
|
||||
<div className="st-meta">
|
||||
<span>Counter-Strike 2</span>
|
||||
<span>{state.modeLabel}</span>
|
||||
|
||||
+164
-1
@@ -1,6 +1,6 @@
|
||||
/* ---------- Tokens ---------- */
|
||||
:root {
|
||||
/* MaxLAN-Farben aus dem Logo: Blau #344A99, Rot #A72A1C auf Schwarz */
|
||||
/* Maxlan-Farben aus dem Logo: Blau #344A99, Rot #A72A1C auf Schwarz */
|
||||
--brand-blue: #344a99;
|
||||
--brand-red: #a72a1c;
|
||||
|
||||
@@ -589,3 +589,166 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
||||
.pull-running span { color: var(--pending); }
|
||||
.pull-ok span { color: var(--ok, #22c55e); }
|
||||
.pull-failed span { color: var(--error); }
|
||||
.admin-toolbar { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; margin: 0 0 14px; }
|
||||
.admin-search { flex: 1 1 240px; min-width: 0; font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--line); border-radius: 4px; padding: 7px 10px; }
|
||||
.admin-filters { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.admin-filter { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: .875rem; color: var(--muted); background: transparent; border: 1px solid var(--line); border-radius: 999px; padding: 4px 12px; cursor: pointer; }
|
||||
.admin-filter:hover { color: var(--text); }
|
||||
.admin-filter.is-active { color: var(--text); border-color: var(--primary); background: color-mix(in srgb, var(--primary) 16%, transparent); }
|
||||
.admin-filter-count { font-variant-numeric: tabular-nums; font-size: .75rem; opacity: .8; }
|
||||
.admin-filter.has-errors .admin-filter-count { color: var(--error); font-weight: 700; opacity: 1; }
|
||||
.admin-error { margin: 8px 0 0; color: var(--error); }
|
||||
.admin-badge.is-archived { background: transparent; color: var(--muted); border: 1px solid var(--line); }
|
||||
.knife-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-bottom: 12px; }
|
||||
.knife-card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.knife-head { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
|
||||
.knife-state { margin-left: auto; }
|
||||
.knife-addr { font: 500 .9rem ui-monospace, SFMono-Regular, Consolas, monospace; align-self: flex-start; padding: 1px 6px; border: 1px solid var(--line); border-radius: 4px; }
|
||||
.knife-new select { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--line); border-radius: 4px; padding: 5px 8px; }
|
||||
.knife-new { align-items: center; }
|
||||
.knife-public { margin-bottom: 24px; }
|
||||
.knife-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.knife-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 10px 14px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
.knife-list code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
|
||||
.knife-list .btn { margin-left: auto; }
|
||||
.knife-mode { display: inline-block; margin: 0; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 700; color: var(--primary); border: 1px solid var(--primary); background: transparent; }
|
||||
.pool-maps { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
.pool-map { position: relative; display: flex; flex-direction: column; justify-content: flex-end; min-height: 78px; isolation: isolate; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; cursor: pointer; opacity: .45; transition: opacity .15s ease, border-color .15s ease; }
|
||||
.pool-map.is-on { opacity: 1; border-color: var(--primary); }
|
||||
.pool-map input { position: absolute; top: 6px; left: 6px; z-index: 2; }
|
||||
|
||||
.pool-map-name { position: relative; z-index: 1; padding: 5px 8px; font-size: .9rem; font-weight: 700; text-shadow: 0 1px 6px rgba(0, 0, 0, .7); }
|
||||
.pool-flows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
|
||||
.pool-flow { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; }
|
||||
.pool-flow strong { min-width: 36px; font-size: .85rem; }
|
||||
.flow-steps { display: flex; flex-wrap: wrap; gap: 4px; list-style: none; margin: 0; padding: 0; }
|
||||
.flow-steps li { font-size: .75rem; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
|
||||
.flow-steps .flow-ban { color: var(--error); border-color: color-mix(in srgb, var(--error) 50%, transparent); }
|
||||
.flow-steps .flow-pick { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 50%, transparent); }
|
||||
.flow-steps .flow-decider { color: var(--text); border-color: var(--primary); }
|
||||
/* Admin-Einstellungen mit Seitenleiste */
|
||||
.settings-layout { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 20px; align-items: start; }
|
||||
.settings-nav { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.settings-nav-title { font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 0 0 4px 10px; }
|
||||
.settings-nav-group { display: flex; flex-direction: column; gap: 2px; }
|
||||
.settings-nav-item { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%; text-align: left; font: inherit; color: var(--text); background: transparent; border: 1px solid transparent; border-left: 3px solid transparent; border-radius: 4px; padding: 7px 10px; cursor: pointer; }
|
||||
.settings-nav-item small { font-size: .75rem; color: var(--muted); }
|
||||
.settings-nav-item:hover { background: var(--surface); }
|
||||
.settings-nav-item.is-active { background: var(--surface); border-color: var(--line); border-left-color: var(--primary); }
|
||||
.settings-content > .admin-settings { margin-bottom: 0; }
|
||||
@media (max-width: 760px) {
|
||||
.settings-layout { grid-template-columns: minmax(0, 1fr); gap: 12px; }
|
||||
.settings-nav { position: static; flex-direction: row; flex-wrap: wrap; gap: 6px; }
|
||||
.settings-nav-group { flex-direction: row; flex-wrap: wrap; gap: 6px; }
|
||||
.settings-nav-title { display: none; }
|
||||
.settings-nav-item { width: auto; white-space: nowrap; border: 1px solid var(--line); border-radius: 999px; padding: 5px 12px; }
|
||||
.settings-nav-item small { display: none; }
|
||||
.settings-nav-item.is-active { border-color: var(--primary); }
|
||||
}
|
||||
|
||||
/* ---------- Startseite: Spieler zuerst ---------- */
|
||||
.home-hero { display: grid; grid-template-columns: minmax(0, 1fr); gap: 32px; align-items: start; margin: 8px 0 28px; }
|
||||
.home-hero.has-mine { grid-template-columns: minmax(0, 1.25fr) minmax(280px, 1fr); }
|
||||
.home-intro h1 { font: 700 clamp(2.6rem, 6vw, 4.4rem)/.95 var(--display); letter-spacing: -.01em; margin: 0 0 14px; max-width: 14ch; }
|
||||
.home-intro .lead { max-width: 58ch; margin: 0 0 22px; }
|
||||
|
||||
/* Code-Eingabe als Eintrittskarte: Abriss links, Code groß in der Mitte */
|
||||
.ticket {
|
||||
--notch: 11px;
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; column-gap: 0;
|
||||
max-width: 620px; background: var(--surface-2); border: 1px solid var(--line-strong);
|
||||
clip-path: polygon(var(--chamfer) 0, 100% 0, 100% calc(100% - var(--chamfer)), calc(100% - var(--chamfer)) 100%, 0 100%, 0 var(--chamfer));
|
||||
}
|
||||
.ticket-stub {
|
||||
align-self: stretch; display: flex; align-items: center; padding: 0 18px 0 22px;
|
||||
font: 700 1.05rem var(--display); color: var(--muted); letter-spacing: .02em;
|
||||
border-right: 2px dashed var(--line-strong); background: color-mix(in srgb, var(--brand-red) 14%, transparent);
|
||||
}
|
||||
.ticket-code {
|
||||
min-width: 0; width: 100%; border: 0; background: transparent; color: var(--text); outline: none;
|
||||
font: 700 clamp(1.9rem, 4.6vw, 2.75rem)/1 var(--display); letter-spacing: .14em; padding: 16px 18px;
|
||||
}
|
||||
.ticket-code::placeholder { color: color-mix(in srgb, var(--muted) 45%, transparent); }
|
||||
.ticket:focus-within { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); }
|
||||
.ticket-go { margin: 0 14px 0 0; }
|
||||
.ticket-msg { grid-column: 1 / -1; margin: 0; padding: 8px 22px 12px; border-top: 1px solid var(--line); }
|
||||
|
||||
/* Eigene Matches */
|
||||
.home-mine h2 { font-size: 1.15rem; margin: 6px 0 10px; }
|
||||
.home-mine ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.mine-card {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 14px; align-items: center;
|
||||
padding: 14px 16px; text-decoration: none; color: var(--text);
|
||||
background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--primary); border-radius: var(--radius);
|
||||
}
|
||||
.mine-card:hover { border-color: var(--line-strong); border-left-color: var(--primary-hover); }
|
||||
.mine-card:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
|
||||
.mine-teams { font: 700 1.35rem/1.1 var(--display); grid-column: 1; }
|
||||
.mine-meta { grid-column: 1; font-size: .85rem; color: var(--muted); }
|
||||
.mine-next { grid-column: 1; font-weight: 600; margin-top: 4px; }
|
||||
.mine-go { grid-column: 2; grid-row: 1 / span 3; }
|
||||
|
||||
/* Vier Schritte */
|
||||
.home-steps { list-style: none; margin: 0 0 36px; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--line); }
|
||||
.home-steps li { position: relative; display: grid; grid-template-columns: auto 1fr; align-content: start; column-gap: 12px; row-gap: 2px; padding: 14px 16px 4px 0; }
|
||||
.step-n {
|
||||
grid-row: 1; align-self: center; width: 34px; height: 34px; display: grid; place-items: center;
|
||||
font: 700 1.2rem var(--display); color: var(--muted); border: 1px solid var(--line-strong); border-radius: 50%;
|
||||
}
|
||||
.step-title { font-weight: 700; align-self: center; }
|
||||
.step-text { grid-column: 2; font-size: .85rem; color: var(--muted); }
|
||||
.home-steps li.is-done .step-n { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 60%, transparent); }
|
||||
.home-steps li.is-current::before { content: ''; position: absolute; top: -2px; left: 0; right: 16px; height: 3px; background: var(--primary); }
|
||||
.home-steps li.is-current .step-n { color: #fff; background: var(--primary); border-color: var(--primary); }
|
||||
|
||||
.home-live { margin-bottom: 36px; }
|
||||
.home-lists { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
|
||||
.home-lists .match-row-meta { flex-wrap: wrap; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.home-hero.has-mine { grid-template-columns: minmax(0, 1fr); }
|
||||
.home-hero.has-mine .home-mine { order: -1; }
|
||||
.home-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.home-lists { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.ticket { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.ticket-stub { grid-column: 1 / -1; border-right: 0; border-bottom: 2px dashed var(--line-strong); padding: 8px 18px; }
|
||||
.ticket-code { letter-spacing: .08em; padding: 12px 14px; }
|
||||
.home-steps { grid-template-columns: minmax(0, 1fr); }
|
||||
.home-steps li { padding: 10px 0 10px 12px; }
|
||||
.home-steps li.is-current::before { top: 8px; bottom: 8px; left: 0; right: auto; width: 3px; height: auto; }
|
||||
.mine-card { grid-template-columns: minmax(0, 1fr); }
|
||||
.mine-go { grid-column: 1; grid-row: auto; justify-self: start; margin-top: 6px; }
|
||||
}
|
||||
/* Seitenwahl per Messerrunde in der Lobby */
|
||||
.knife-round { margin: 14px 0; padding: 14px 16px; border: 1px solid var(--line); border-left: 3px solid var(--brand-red); border-radius: var(--radius); background: var(--surface-2); text-align: left; }
|
||||
.knife-round h3 { margin: 0 0 6px; font-size: 1.05rem; }
|
||||
.knife-round .knife-list { margin-top: 10px; }
|
||||
.knife-decide { margin: 6px 0 4px; }
|
||||
.knife-decide .row { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
||||
.knife-team-a { border-color: var(--team-a); color: var(--team-a); }
|
||||
.knife-team-b { border-color: var(--team-b); color: var(--team-b); }
|
||||
/* Chat im Match */
|
||||
.chat-box { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
|
||||
.chat-tabs { display: flex; border-bottom: 1px solid var(--line); }
|
||||
.chat-tab { position: relative; flex: 1; font: 700 1rem var(--display); color: var(--muted); background: transparent; border: 0; border-bottom: 3px solid transparent; padding: 12px 14px 10px; cursor: pointer; }
|
||||
.chat-tab:hover { color: var(--text); }
|
||||
.chat-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
|
||||
.chat-tab-a.is-active { border-bottom-color: var(--team-a); }
|
||||
.chat-tab-b.is-active { border-bottom-color: var(--team-b); }
|
||||
.chat-unread { display: inline-block; width: 8px; height: 8px; margin-left: 6px; border-radius: 50%; background: var(--brand-red); vertical-align: middle; }
|
||||
.chat-list { list-style: none; margin: 0; padding: 10px 16px; height: 300px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.chat-empty { margin: auto 0; text-align: center; }
|
||||
.chat-msg { display: flex; flex-direction: column; gap: 1px; max-width: 92%; }
|
||||
.chat-msg.is-mine { align-self: flex-end; text-align: right; }
|
||||
.chat-meta { display: flex; gap: 6px; align-items: baseline; font-size: .8rem; }
|
||||
.chat-msg.is-mine .chat-meta { justify-content: flex-end; }
|
||||
.chat-org { color: var(--text); }
|
||||
.chat-role { font-size: .7rem; padding: 0 6px; border: 1px solid var(--line-strong); border-radius: 999px; color: var(--muted); }
|
||||
.chat-text { display: inline-block; padding: 6px 10px; border-radius: 10px; background: var(--surface-2); overflow-wrap: anywhere; }
|
||||
.chat-msg.is-mine .chat-text { background: color-mix(in srgb, var(--primary) 30%, var(--surface-2)); }
|
||||
.chat-form { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); }
|
||||
.chat-form input { flex: 1; min-width: 0; }
|
||||
.chat-closed { margin: 0; padding: 10px 16px; border-top: 1px solid var(--line); }
|
||||
.chat-box .field-error { margin: 0 16px 10px; }
|
||||
Binary file not shown.
Binary file not shown.
+137
-17
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Map-Pools und Veto-Abläufe. Schritte: ban:X, pick:X, side:X, decider. X = erstes Team (startet das Veto), Y = zweites Team. 'side' folgt immer auf einen Pick bzw. auf 'decider' und bezieht sich auf diese Map. Ungültige Abläufe werden beim Start gemeldet und das Format ist dann nicht auswählbar. Optional pro Map: image = Bild-URL oder Pfad (z. B. /maps/de_dust2.jpg aus client/public). Standardbilder: github.com/ghostcap-gaming/cs2-map-images (Bilder aus dem Spiel, Rechte bei Valve).",
|
||||
"$comment": "Map-Pools und Veto-Abläufe. Schritte: ban:X, pick:X, side:X, decider. X = erstes Team (startet das Veto), Y = zweites Team. 'side' folgt immer auf einen Pick bzw. auf 'decider' und bezieht sich auf diese Map. Welche Maps gespielt werden, wählt der Admin im Admin-Bereich (Einstellungen → Map-Pools); 'active': false = nur im Katalog. Die hier hinterlegten Abläufe gelten, wenn sie zur Anzahl der aktiven Maps passen, sonst erzeugt die Anwendung einen passenden Ablauf. Optional pro Map: image = Bild-URL oder Pfad (z. B. /maps/de_dust2.jpg aus client/public). Standardbilder: github.com/ghostcap-gaming/cs2-map-images (Bilder aus dem Spiel, Rechte bei Valve).",
|
||||
"pools": {
|
||||
"competitive": {
|
||||
"label": "Competitive 5 gegen 5",
|
||||
@@ -7,18 +7,104 @@
|
||||
"gameType": 0,
|
||||
"gameMode": 1,
|
||||
"maps": [
|
||||
{ "id": "de_ancient", "name": "Ancient", "color": "#3f8f5a", "image": "/maps/de_ancient.webp" },
|
||||
{ "id": "de_anubis", "name": "Anubis", "color": "#c79a3d", "image": "/maps/de_anubis.webp" },
|
||||
{ "id": "de_dust2", "name": "Dust II", "color": "#d3a45c", "image": "/maps/de_dust2.webp" },
|
||||
{ "id": "de_inferno", "name": "Inferno", "color": "#c4573a", "image": "/maps/de_inferno.webp" },
|
||||
{ "id": "de_mirage", "name": "Mirage", "color": "#b98a4a", "image": "/maps/de_mirage.webp" },
|
||||
{ "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf", "image": "/maps/de_nuke.webp" },
|
||||
{ "id": "de_train", "name": "Train", "color": "#7a7f8a", "image": "/maps/de_train.webp" }
|
||||
{
|
||||
"id": "de_ancient",
|
||||
"name": "Ancient",
|
||||
"color": "#3f8f5a",
|
||||
"image": "/maps/de_ancient.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_anubis",
|
||||
"name": "Anubis",
|
||||
"color": "#c79a3d",
|
||||
"image": "/maps/de_anubis.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_cache",
|
||||
"name": "Cache",
|
||||
"color": "#6f8a4a",
|
||||
"image": "/maps/de_cache.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_dust2",
|
||||
"name": "Dust II",
|
||||
"color": "#d3a45c",
|
||||
"image": "/maps/de_dust2.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_inferno",
|
||||
"name": "Inferno",
|
||||
"color": "#c4573a",
|
||||
"image": "/maps/de_inferno.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_mirage",
|
||||
"name": "Mirage",
|
||||
"color": "#b98a4a",
|
||||
"image": "/maps/de_mirage.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_nuke",
|
||||
"name": "Nuke",
|
||||
"color": "#4a8fbf",
|
||||
"image": "/maps/de_nuke.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_overpass",
|
||||
"name": "Overpass",
|
||||
"color": "#5f8f7a",
|
||||
"image": "/maps/de_overpass.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_train",
|
||||
"name": "Train",
|
||||
"color": "#7a7f8a",
|
||||
"image": "/maps/de_train.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_vertigo",
|
||||
"name": "Vertigo",
|
||||
"color": "#8a74b8",
|
||||
"image": "/maps/de_vertigo.webp"
|
||||
}
|
||||
],
|
||||
"flows": {
|
||||
"bo1": ["ban:X", "ban:Y", "ban:X", "ban:Y", "ban:X", "ban:Y", "decider", "side:X"],
|
||||
"bo3": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "ban:X", "ban:Y", "decider", "side:X"],
|
||||
"bo5": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "pick:X", "side:Y", "pick:Y", "side:X", "decider", "side:Y"]
|
||||
"bo1": [
|
||||
"ban:X",
|
||||
"ban:Y",
|
||||
"ban:X",
|
||||
"ban:Y",
|
||||
"ban:X",
|
||||
"ban:Y",
|
||||
"decider",
|
||||
"side:X"
|
||||
],
|
||||
"bo3": [
|
||||
"ban:X",
|
||||
"ban:Y",
|
||||
"pick:X",
|
||||
"side:Y",
|
||||
"pick:Y",
|
||||
"side:X",
|
||||
"ban:X",
|
||||
"ban:Y",
|
||||
"decider",
|
||||
"side:X"
|
||||
],
|
||||
"bo5": [
|
||||
"ban:X",
|
||||
"ban:Y",
|
||||
"pick:X",
|
||||
"side:Y",
|
||||
"pick:Y",
|
||||
"side:X",
|
||||
"pick:X",
|
||||
"side:Y",
|
||||
"pick:Y",
|
||||
"side:X",
|
||||
"decider",
|
||||
"side:Y"
|
||||
]
|
||||
}
|
||||
},
|
||||
"wingman": {
|
||||
@@ -27,14 +113,48 @@
|
||||
"gameType": 0,
|
||||
"gameMode": 2,
|
||||
"maps": [
|
||||
{ "id": "de_inferno", "name": "Inferno", "color": "#c4573a", "image": "/maps/de_inferno.webp" },
|
||||
{ "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf", "image": "/maps/de_nuke.webp" },
|
||||
{ "id": "de_overpass", "name": "Overpass", "color": "#5f8f7a", "image": "/maps/de_overpass.webp" },
|
||||
{ "id": "de_vertigo", "name": "Vertigo", "color": "#8a74b8", "image": "/maps/de_vertigo.webp" }
|
||||
{
|
||||
"id": "de_inferno",
|
||||
"name": "Inferno",
|
||||
"color": "#c4573a",
|
||||
"image": "/maps/de_inferno.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_nuke",
|
||||
"name": "Nuke",
|
||||
"color": "#4a8fbf",
|
||||
"image": "/maps/de_nuke.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_overpass",
|
||||
"name": "Overpass",
|
||||
"color": "#5f8f7a",
|
||||
"image": "/maps/de_overpass.webp"
|
||||
},
|
||||
{
|
||||
"id": "de_vertigo",
|
||||
"name": "Vertigo",
|
||||
"color": "#8a74b8",
|
||||
"image": "/maps/de_vertigo.webp"
|
||||
}
|
||||
],
|
||||
"flows": {
|
||||
"bo1": ["ban:X", "ban:Y", "ban:X", "decider", "side:Y"],
|
||||
"bo3": ["ban:X", "pick:Y", "side:X", "pick:X", "side:Y", "decider", "side:X"]
|
||||
"bo1": [
|
||||
"ban:X",
|
||||
"ban:Y",
|
||||
"ban:X",
|
||||
"decider",
|
||||
"side:Y"
|
||||
],
|
||||
"bo3": [
|
||||
"ban:X",
|
||||
"pick:Y",
|
||||
"side:X",
|
||||
"pick:X",
|
||||
"side:Y",
|
||||
"decider",
|
||||
"side:X"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ function bearer(req) {
|
||||
* (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, settings }) {
|
||||
export function createAdminRouter({ service, manager, config, settings, knife, pools }) {
|
||||
const router = express.Router();
|
||||
const sessions = new Map(); // Token -> Ablaufzeit (ms)
|
||||
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
|
||||
@@ -125,6 +125,33 @@ export function createAdminRouter({ service, manager, config, settings }) {
|
||||
return hostView();
|
||||
}));
|
||||
|
||||
// Map-Pools: welche Maps gespielt werden. Der Veto-Ablauf passt sich der Anzahl an.
|
||||
const poolView = () => ({
|
||||
pools: Object.values(pools).map((p) => ({
|
||||
mode: p.mode,
|
||||
label: p.label,
|
||||
maps: p.allMaps.map((m) => ({ ...m, active: p.maps.some((a) => a.id === m.id) })),
|
||||
formats: p.formats,
|
||||
})),
|
||||
});
|
||||
router.get('/map-pools', requireAdmin, wrap(() => poolView()));
|
||||
router.put('/map-pools', requireAdmin, wrap((req) => {
|
||||
settings.saveMapSelection(pools, req.body?.mode, req.body?.maps);
|
||||
return poolView();
|
||||
}));
|
||||
|
||||
// Messer-Server für die Seitenwahl
|
||||
router.get('/knife', requireAdmin, wrap(() => ({
|
||||
servers: knife.list(),
|
||||
modes: knife.modes(),
|
||||
maxServers: config.knife.maxServers,
|
||||
})));
|
||||
router.post('/knife', requireAdmin, wrap((req) => knife.create({ map: req.body?.map, mode: req.body?.mode })));
|
||||
router.post('/knife/:id/reset', requireAdmin, wrap((req) => knife.reset(req.params.id)));
|
||||
router.post('/knife/:id/round', requireAdmin, wrap((req) => knife.startRound(req.params.id)));
|
||||
router.post('/knife/:id/restart', requireAdmin, wrap((req) => knife.restart(req.params.id)));
|
||||
router.delete('/knife/:id', requireAdmin, wrap((req) => knife.remove(req.params.id)));
|
||||
|
||||
router.get('/notifications', requireAdmin, wrap(() => service.notificationSettings()));
|
||||
router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events)));
|
||||
router.post('/notifications/test', requireAdmin, wrap(async () => {
|
||||
@@ -137,9 +164,20 @@ export function createAdminRouter({ service, manager, config, settings }) {
|
||||
}));
|
||||
|
||||
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
||||
router.get('/matches', requireAdmin, wrap(async () => {
|
||||
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))), hosts: await manager.hostUsage() };
|
||||
// Auslastung und Speicher der Docker-Hosts mit den Servern darauf
|
||||
router.get('/hosts', requireAdmin, wrap(async () => ({ hosts: await manager.hostUsage() })));
|
||||
|
||||
// ?archived=1 liefert das Archiv, sonst alle nicht archivierten Matches
|
||||
router.get('/matches', requireAdmin, wrap(async (req) => {
|
||||
const archived = req.query.archived === '1';
|
||||
const ids = service.db.prepare(archived
|
||||
? 'SELECT id FROM matches WHERE archived_at IS NOT NULL ORDER BY archived_at DESC LIMIT 300'
|
||||
: 'SELECT id FROM matches WHERE archived_at IS NULL ORDER BY created_at DESC LIMIT 150').all();
|
||||
const archivedCount = service.db.prepare('SELECT COUNT(*) AS n FROM matches WHERE archived_at IS NOT NULL').get().n;
|
||||
return {
|
||||
matches: ids.map(({ id }) => service.snapshot(id, adminViewer(id))),
|
||||
archivedCount,
|
||||
};
|
||||
}));
|
||||
|
||||
const action = (path, fn) => router.post(`/matches/:id/${path}`, requireAdmin, wrap((req) => {
|
||||
@@ -152,10 +190,12 @@ export function createAdminRouter({ service, manager, config, settings }) {
|
||||
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||
action('server/retry', (v) => manager.retry(v));
|
||||
action('server/warmup-end', (v) => manager.endWarmup(v));
|
||||
action('archive', (v) => service.setArchived(v.matchId, true));
|
||||
action('unarchive', (v) => service.setArchived(v.matchId, false));
|
||||
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/start', (v, b) => service.startVeto(v, { firstTeam: b.firstTeam }));
|
||||
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 },
|
||||
|
||||
+8
-2
@@ -11,6 +11,7 @@ import { MatchService } from './matchService.js';
|
||||
import { attachRealtime } from './realtime.js';
|
||||
import { createServerAdapter } from './servers/adapter.js';
|
||||
import { ServerManager } from './servers/serverManager.js';
|
||||
import { KnifeServers } from './servers/knifeServers.js';
|
||||
import { ServerSettings } from './serverSettings.js';
|
||||
|
||||
/** Baut die komplette Anwendung zusammen (auch für Tests nutzbar). */
|
||||
@@ -20,6 +21,8 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
||||
const settings = new ServerSettings({ db, config });
|
||||
settings.load();
|
||||
const pools = loadMapPools(config.mapPoolFile);
|
||||
// Im Admin-Bereich gewählte Maps (Einstellungen → Map-Pools)
|
||||
settings.applyMapSelection(pools);
|
||||
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
|
||||
const service = new MatchService({ db, pools, config, notifier });
|
||||
|
||||
@@ -29,6 +32,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
||||
});
|
||||
manager = new ServerManager({ db, service, adapter, config, logger });
|
||||
manager.init();
|
||||
const knife = new KnifeServers({ db, adapter, config, pools, logger });
|
||||
service.startVetoTimer();
|
||||
|
||||
const app = express();
|
||||
@@ -39,7 +43,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
||||
res.set('Referrer-Policy', 'no-referrer');
|
||||
next();
|
||||
});
|
||||
app.use('/api', createApiRouter({ service, manager, config, pools, adapter, settings }));
|
||||
app.use('/api', createApiRouter({ service, manager, config, pools, adapter, settings, knife }));
|
||||
|
||||
// Gebautes Frontend ausliefern (npm run build), SPA-Fallback auf index.html.
|
||||
const dist = path.join(REPO_ROOT, 'client', 'dist');
|
||||
@@ -52,15 +56,17 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
||||
const io = attachRealtime(server, { service, config });
|
||||
|
||||
return {
|
||||
app, server, io, db, service, manager, adapter,
|
||||
app, server, io, db, service, manager, adapter, knife,
|
||||
async start() {
|
||||
await manager.resume();
|
||||
manager.startLoops();
|
||||
knife.startLoop();
|
||||
await new Promise((resolve) => server.listen(config.port, resolve));
|
||||
return server.address().port;
|
||||
},
|
||||
async close() {
|
||||
manager.stopLoops();
|
||||
knife.stopLoop();
|
||||
service.stopVetoTimer();
|
||||
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
|
||||
await manager.idle(5000);
|
||||
|
||||
+10
-1
@@ -109,6 +109,15 @@ export function loadConfig(overrides = {}) {
|
||||
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
||||
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
|
||||
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
|
||||
// Messer-Server für die Seitenwahl (im Admin-Bereich startbar)
|
||||
knife: {
|
||||
map: str('KNIFE_MAP', 'de_mirage'),
|
||||
// Zeit zum Verbinden, bevor die Messerrunde beginnt
|
||||
warmupSeconds: int('KNIFE_WARMUP_SECONDS', 90),
|
||||
maxServers: int('KNIFE_MAX_SERVERS', 4),
|
||||
},
|
||||
// Abgeschlossene oder abgebrochene Matches nach so vielen Tagen automatisch archivieren. 0 = nie
|
||||
archiveAfterDays: int('ARCHIVE_AFTER_DAYS', 3),
|
||||
|
||||
match: {
|
||||
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
||||
@@ -146,7 +155,7 @@ export function loadConfig(overrides = {}) {
|
||||
// Abstand des CSTV-Ports zum Spielport, z. B. Spiel 27015 -> CSTV 27115
|
||||
tvPortOffset: int('CS2_TV_PORT_OFFSET', 100),
|
||||
// Anfang des Servernamens im Serverbrowser, danach folgen die Teamnamen
|
||||
serverNamePrefix: str('CS2_SERVERNAME_PREFIX', 'MaxLAN'),
|
||||
serverNamePrefix: str('CS2_SERVERNAME_PREFIX', 'Maxlan'),
|
||||
// Optional: mehrere Docker-Hosts als JSON-Liste, z. B.
|
||||
// [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
|
||||
hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')),
|
||||
|
||||
@@ -97,6 +97,41 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS events_match ON events(match_id, id);
|
||||
-- Messer-Server für die Seitenwahl: laufen dauerhaft, gehören keinem Match
|
||||
CREATE TABLE IF NOT EXISTS knife_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
number INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
mode TEXT NOT NULL DEFAULT 'competitive',
|
||||
map TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT,
|
||||
handle TEXT,
|
||||
host TEXT,
|
||||
port INTEGER,
|
||||
tv_port INTEGER,
|
||||
rcon_password TEXT NOT NULL,
|
||||
player_count INTEGER,
|
||||
restarts INTEGER NOT NULL DEFAULT 0,
|
||||
started_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Chat im Match: channel = 'match' (beide Teams + Organisator) oder 'A' / 'B' (nur das eigene Team)
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||
channel TEXT NOT NULL,
|
||||
player_id TEXT,
|
||||
author TEXT NOT NULL,
|
||||
team TEXT,
|
||||
organizer INTEGER NOT NULL DEFAULT 0,
|
||||
text TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chat_by_match ON chat_messages (match_id, channel, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
@@ -130,4 +165,7 @@ function migrate(db) {
|
||||
}
|
||||
const serverColumns = columns('servers');
|
||||
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
||||
// 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('knife_servers').has('mode')) db.exec("ALTER TABLE knife_servers ADD COLUMN mode TEXT NOT NULL DEFAULT 'competitive'");
|
||||
}
|
||||
+7
-3
@@ -14,7 +14,7 @@ function accessTokens(req) {
|
||||
return [bearer(req), req.get('x-player-token')?.trim() || null];
|
||||
}
|
||||
|
||||
export function createApiRouter({ service, manager, config, pools, adapter, settings }) {
|
||||
export function createApiRouter({ service, manager, config, pools, adapter, settings, knife }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Live-Logs vom CS2-Server (logaddress_add_http). Vor dem JSON-Parser, weil CS2 Klartext sendet.
|
||||
@@ -32,7 +32,7 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
|
||||
});
|
||||
|
||||
router.use(express.json({ limit: '32kb' }));
|
||||
router.use('/admin', createAdminRouter({ service, manager, config, settings }));
|
||||
router.use('/admin', createAdminRouter({ service, manager, config, settings, knife, pools }));
|
||||
|
||||
const limiters = {
|
||||
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
|
||||
@@ -71,6 +71,9 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
|
||||
|
||||
router.get('/matches', wrap(() => ({ matches: service.listMatches() })));
|
||||
|
||||
// Messer-Server für die Seitenwahl: offen, ohne Passwort, deshalb für alle sichtbar
|
||||
router.get('/knife-servers', wrap(() => ({ servers: knife.list().filter((k) => k.status === 'running') })));
|
||||
|
||||
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
||||
router.get('/stream/overview', wrap((req) => {
|
||||
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
|
||||
@@ -110,11 +113,12 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
|
||||
const action = (path, handler) => router.post(`/matches/:id/${path}`, limit('action'), wrap((req) => handler(viewerFor(req), req.body ?? {})));
|
||||
|
||||
action('ready', (v, b) => service.setReady(v, Boolean(b.ready)));
|
||||
action('chat', (v, b) => service.postChat(v, { channel: b.channel, text: b.text }));
|
||||
action('leave', (v) => service.leave(v));
|
||||
action('kick', (v, b) => service.kick(v, b.playerId));
|
||||
action('captain', (v, b) => service.setCaptain(v, b.playerId));
|
||||
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
|
||||
action('veto/start', (v) => service.startVeto(v));
|
||||
action('veto/start', (v, b) => service.startVeto(v, { firstTeam: b.firstTeam }));
|
||||
action('veto/action', (v, b) => service.vetoAction(v, b));
|
||||
action('server/retry', (v) => manager.retry(v));
|
||||
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||
|
||||
+40
-16
@@ -1,5 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { BEST_OF, validateFlow } from './veto.js';
|
||||
import { BEST_OF, generateFlow, validateFlow } from './veto.js';
|
||||
|
||||
/**
|
||||
* Lädt die Map-Pools und markiert jede Kombination aus Modus und Format als unterstützt oder nicht.
|
||||
@@ -22,32 +22,56 @@ export function buildMapPools(raw) {
|
||||
}
|
||||
ids.add(map.id);
|
||||
}
|
||||
const formats = {};
|
||||
for (const format of Object.keys(BEST_OF)) {
|
||||
const flow = pool.flows?.[format];
|
||||
if (!flow) {
|
||||
formats[format] = { supported: false, reason: 'Kein Veto-Ablauf für dieses Format hinterlegt' };
|
||||
continue;
|
||||
}
|
||||
const errors = validateFlow(flow, pool.maps.length, format);
|
||||
formats[format] = errors.length
|
||||
? { supported: false, reason: errors.join('; ') }
|
||||
: { supported: true, flow };
|
||||
if (errors.length) console.warn(`[map-pools] ${mode}/${format} deaktiviert: ${errors.join('; ')}`);
|
||||
}
|
||||
// "active": false = Map ist im Katalog, wird aber erst nach Auswahl im Admin-Bereich gespielt
|
||||
const active = pool.maps.filter((m) => m.active !== false).map((m) => m.id);
|
||||
pools[mode] = {
|
||||
mode,
|
||||
label: pool.label ?? mode,
|
||||
teamSize: pool.teamSize,
|
||||
gameType: pool.gameType,
|
||||
gameMode: pool.gameMode,
|
||||
maps: pool.maps,
|
||||
formats,
|
||||
allMaps: pool.maps.map(({ active: _ignored, ...m }) => m),
|
||||
flows: pool.flows ?? {},
|
||||
maps: [],
|
||||
formats: {},
|
||||
};
|
||||
setActiveMaps(pools[mode], active, { warn: true });
|
||||
}
|
||||
return pools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Veto-Abläufe für die aktiven Maps: Ein in der Konfiguration hinterlegter Ablauf gilt, wenn er zur Map-Anzahl passt,
|
||||
* sonst erzeugt die Anwendung einen passenden Ablauf.
|
||||
*/
|
||||
export function formatsFor(pool, mapCount, { warn = false } = {}) {
|
||||
const formats = {};
|
||||
for (const format of Object.keys(BEST_OF)) {
|
||||
const configured = pool.flows?.[format];
|
||||
if (configured && validateFlow(configured, mapCount, format).length === 0) {
|
||||
formats[format] = { supported: true, flow: configured, generated: false };
|
||||
continue;
|
||||
}
|
||||
const flow = generateFlow(mapCount, format);
|
||||
if (!flow) {
|
||||
formats[format] = { supported: false, reason: `${format.toUpperCase()} braucht mindestens ${Math.max(BEST_OF[format], 2)} Maps` };
|
||||
continue;
|
||||
}
|
||||
const errors = validateFlow(flow, mapCount, format);
|
||||
formats[format] = errors.length ? { supported: false, reason: errors.join('; ') } : { supported: true, flow, generated: true };
|
||||
if (errors.length && warn) console.warn(`[map-pools] ${pool.mode}/${format} deaktiviert: ${errors.join('; ')}`);
|
||||
}
|
||||
return formats;
|
||||
}
|
||||
|
||||
/** Aktive Maps eines Pools setzen (Reihenfolge wie im Katalog) und die Formate neu berechnen. */
|
||||
export function setActiveMaps(pool, ids, options) {
|
||||
const wanted = new Set(ids);
|
||||
pool.maps = pool.allMaps.filter((m) => wanted.has(m.id));
|
||||
pool.formats = formatsFor(pool, pool.maps.length, options);
|
||||
return pool;
|
||||
}
|
||||
|
||||
export function publicPools(pools) {
|
||||
return Object.values(pools).map((p) => ({
|
||||
mode: p.mode,
|
||||
|
||||
+113
-4
@@ -41,9 +41,13 @@ function cleanSteamId(value) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
const CHAT_MAX_LENGTH = 300;
|
||||
const CHAT_HISTORY = 200;
|
||||
|
||||
export class MatchService extends EventEmitter {
|
||||
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
|
||||
super();
|
||||
this.chatRate = new Map();
|
||||
this.db = db;
|
||||
this.pools = pools;
|
||||
this.config = config;
|
||||
@@ -150,7 +154,8 @@ export class MatchService extends EventEmitter {
|
||||
if (!formatInfo?.supported) {
|
||||
throw new HttpError(400, `${String(format).toUpperCase()} wird für ${pool.label} nicht unterstützt`, 'unsupported_format');
|
||||
}
|
||||
if (!['coin', 'A', 'B'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input');
|
||||
// knife = Seitenwahl per Messerrunde: der Organisator legt nach der Runde fest, wer das Veto beginnt
|
||||
if (!['coin', 'A', 'B', 'knife'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input');
|
||||
const nameA = cleanName(teamA, 'Name von Team A');
|
||||
const nameB = cleanName(teamB, 'Name von Team B');
|
||||
if (nameA.toLowerCase() === nameB.toLowerCase()) {
|
||||
@@ -326,7 +331,11 @@ export class MatchService extends EventEmitter {
|
||||
|
||||
// ---------- Veto ----------
|
||||
|
||||
startVeto(viewer) {
|
||||
/**
|
||||
* Veto starten. Bei Seitenwahl per Messerrunde (veto_start = knife) gibt der Organisator mit
|
||||
* `firstTeam` an, welches Team das Veto beginnt.
|
||||
*/
|
||||
startVeto(viewer, { firstTeam: chosen } = {}) {
|
||||
this.require(viewer, 'organizer');
|
||||
this.db.transaction(() => {
|
||||
const match = this.getMatch(viewer.matchId);
|
||||
@@ -335,11 +344,16 @@ export class MatchService extends EventEmitter {
|
||||
if (!readiness.A.ready || !readiness.B.ready) {
|
||||
throw new HttpError(409, 'Beide Teams müssen vollständig und bereit sein', 'teams_not_ready');
|
||||
}
|
||||
const firstTeam = match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B');
|
||||
const knife = match.veto_start === 'knife';
|
||||
if (knife && !['A', 'B'].includes(chosen)) {
|
||||
throw new HttpError(400, 'Nach der Messerrunde festlegen, welches Team das Veto beginnt', 'first_team_required');
|
||||
}
|
||||
const firstTeam = knife ? chosen : match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B');
|
||||
this.db
|
||||
.prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?")
|
||||
.run(firstTeam, this.now(), this.now(), match.id);
|
||||
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
|
||||
const how = knife ? 'nach der Messerrunde vom Organisator festgelegt'
|
||||
: match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
|
||||
this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
|
||||
this.notify('veto_start', match.id, {
|
||||
title: '🎮 Veto gestartet',
|
||||
@@ -625,6 +639,98 @@ export class MatchService extends EventEmitter {
|
||||
.run(matchId, match.current_map_number);
|
||||
}
|
||||
|
||||
// ---------- Chat ----------
|
||||
|
||||
/**
|
||||
* Chat-Kanäle, die dieser Zugang sieht: der Match-Chat für Spieler beider Teams und den Organisator,
|
||||
* dazu der Team-Chat des eigenen Teams. Zuschauer und Gäste sehen keinen Chat.
|
||||
*/
|
||||
chatChannels(match, viewer) {
|
||||
const channels = [];
|
||||
if (viewer.isOrganizer || viewer.playerId) channels.push({ key: 'match', label: 'Match-Chat' });
|
||||
if (viewer.playerId && (viewer.team === 'A' || viewer.team === 'B')) {
|
||||
channels.push({ key: viewer.team, label: `Team ${this.teamName(match, viewer.team)}` });
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
chatView(match, viewer) {
|
||||
const channels = this.chatChannels(match, viewer);
|
||||
if (!channels.length) return null;
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM (SELECT * FROM chat_messages WHERE match_id = ? AND channel = ? ORDER BY id DESC LIMIT ${CHAT_HISTORY})
|
||||
ORDER BY id
|
||||
`);
|
||||
const messages = {};
|
||||
for (const c of channels) {
|
||||
messages[c.key] = stmt.all(match.id, c.key).map((m) => ({
|
||||
id: m.id,
|
||||
author: m.author,
|
||||
team: m.team,
|
||||
organizer: !!m.organizer,
|
||||
text: m.text,
|
||||
at: m.created_at,
|
||||
mine: viewer.playerId ? m.player_id === viewer.playerId : !!m.organizer && !m.player_id,
|
||||
}));
|
||||
}
|
||||
return { channels, messages, canWrite: !CLOSED_STATUSES.includes(match.status), maxLength: CHAT_MAX_LENGTH };
|
||||
}
|
||||
|
||||
/** Nachricht in einen Kanal schreiben. Nach Matchende ist der Chat nur noch lesbar. */
|
||||
postChat(viewer, { channel, text } = {}) {
|
||||
const match = this.getMatch(viewer.matchId);
|
||||
if (!this.chatChannels(match, viewer).some((c) => c.key === channel)) {
|
||||
throw new HttpError(403, 'In diesem Chat darfst du nicht schreiben', 'forbidden');
|
||||
}
|
||||
if (CLOSED_STATUSES.includes(match.status)) throw new HttpError(409, 'Das Match ist beendet, der Chat ist nur noch lesbar', 'match_closed');
|
||||
const clean = String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (!clean) throw new HttpError(400, 'Die Nachricht ist leer', 'invalid_input');
|
||||
if (clean.length > CHAT_MAX_LENGTH) throw new HttpError(400, `Höchstens ${CHAT_MAX_LENGTH} Zeichen`, 'invalid_input');
|
||||
|
||||
// Gegen Spam: höchstens 5 Nachrichten in 10 Sekunden pro Person
|
||||
const who = `${match.id}:${viewer.playerId ?? 'org'}`;
|
||||
const now = this.now();
|
||||
const recent = (this.chatRate.get(who) ?? []).filter((t) => now - t < 10_000);
|
||||
if (recent.length >= 5) throw new HttpError(429, 'Zu viele Nachrichten, kurz warten', 'rate_limited');
|
||||
this.chatRate.set(who, [...recent, now]);
|
||||
|
||||
const player = viewer.playerId ? this.db.prepare('SELECT nickname, team FROM players WHERE id = ?').get(viewer.playerId) : null;
|
||||
this.db.prepare(`
|
||||
INSERT INTO chat_messages (match_id, channel, player_id, author, team, organizer, text, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(match.id, channel, viewer.playerId ?? null, player?.nickname ?? 'Organisator', player?.team ?? null,
|
||||
viewer.isOrganizer ? 1 : 0, clean, now);
|
||||
this.changed(match.id);
|
||||
}
|
||||
|
||||
/** Abgeschlossenes Match ins Archiv legen bzw. zurückholen. */
|
||||
setArchived(matchId, archived) {
|
||||
const match = this.getMatch(matchId);
|
||||
if (archived && !CLOSED_STATUSES.includes(match.status)) {
|
||||
throw new HttpError(409, 'Nur abgeschlossene oder abgebrochene Matches lassen sich archivieren', 'not_closed');
|
||||
}
|
||||
this.db.prepare('UPDATE matches SET archived_at = ? WHERE id = ?').run(archived ? this.now() : null, matchId);
|
||||
this.log(matchId, 'status', archived ? 'Match archiviert (Admin)' : 'Match aus dem Archiv geholt (Admin)');
|
||||
this.changed(matchId);
|
||||
}
|
||||
|
||||
/** Abgeschlossene Matches nach `archiveAfterDays` Tagen automatisch archivieren. Liefert die Anzahl. */
|
||||
archiveClosed(now = this.now()) {
|
||||
const days = this.config.archiveAfterDays;
|
||||
if (!days) return 0;
|
||||
const cutoff = now - days * 86_400_000;
|
||||
const ids = this.db.prepare(`
|
||||
SELECT id FROM matches
|
||||
WHERE archived_at IS NULL AND status IN ('finished', 'cancelled') AND COALESCE(finished_at, updated_at) < ?
|
||||
`).all(cutoff).map((r) => r.id);
|
||||
const stmt = this.db.prepare('UPDATE matches SET archived_at = ? WHERE id = ?');
|
||||
for (const id of ids) {
|
||||
stmt.run(now, id);
|
||||
this.log(id, 'status', `Match automatisch archiviert (nach ${days} Tagen)`);
|
||||
}
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
close(viewer, status) {
|
||||
this.require(viewer, 'organizer');
|
||||
this.closeMatch(viewer.matchId, status);
|
||||
@@ -881,6 +987,8 @@ export class MatchService extends EventEmitter {
|
||||
status: match.status,
|
||||
statusLabel: MATCH_STATUS[match.status],
|
||||
createdAt: match.created_at,
|
||||
finishedAt: match.finished_at ?? null,
|
||||
archivedAt: match.archived_at ?? null,
|
||||
updatedAt: match.updated_at,
|
||||
vetoStart: match.veto_start,
|
||||
currentMapNumber: match.current_map_number,
|
||||
@@ -917,6 +1025,7 @@ export class MatchService extends EventEmitter {
|
||||
seriesScore: seriesScore(seriesRows),
|
||||
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
|
||||
server: this.serverView(match, viewer),
|
||||
chat: this.chatView(match, viewer),
|
||||
events: publicView
|
||||
? []
|
||||
: this.db
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
import { HttpError } from './matchService.js';
|
||||
import { parseDockerHosts } from './config.js';
|
||||
import { setActiveMaps } from './mapPools.js';
|
||||
|
||||
const SETTINGS_KEY = 'server_settings';
|
||||
const HOSTS_KEY = 'docker_hosts';
|
||||
const MAPS_KEY = 'map_selection';
|
||||
|
||||
/**
|
||||
* Änderbare Felder. `get`/`set` greifen auf das gemeinsame config-Objekt zu, das Match-Service,
|
||||
@@ -30,6 +32,8 @@ export const SERVER_SETTING_FIELDS = [
|
||||
get: (c) => c.idleTimeoutMinutes, set: (c, v) => { c.idleTimeoutMinutes = v; } },
|
||||
{ key: 'maxLifetimeHours', group: 'times', label: 'Maximale Laufzeit eines Servers in Stunden', type: 'int', min: 1, max: 72, live: false,
|
||||
get: (c) => c.maxLifetimeHours, set: (c, v) => { c.maxLifetimeHours = v; } },
|
||||
{ key: 'archiveAfterDays', group: 'times', label: 'Abgeschlossene Matches archivieren nach Tagen (0 = nie)', type: 'int', min: 0, max: 365, live: false,
|
||||
get: (c) => c.archiveAfterDays, set: (c, v) => { c.archiveAfterDays = v; } },
|
||||
{ key: 'startTimeoutMinutes', group: 'times', label: 'Startzeit eines Servers in Minuten (inkl. Download)', type: 'int', min: 5, max: 240, live: false,
|
||||
get: (c) => c.docker.startTimeoutMinutes, set: (c, v) => { c.docker.startTimeoutMinutes = v; } },
|
||||
];
|
||||
@@ -190,4 +194,31 @@ export class ServerSettings {
|
||||
hostsOverridden() {
|
||||
return this.readJson(HOSTS_KEY) != null;
|
||||
}
|
||||
|
||||
/** Beim Start: im Admin-Bereich gewählte Maps auf die Pools legen. Unbekannte Maps werden ignoriert. */
|
||||
applyMapSelection(pools) {
|
||||
const saved = this.readJson(MAPS_KEY) ?? {};
|
||||
for (const [mode, ids] of Object.entries(saved)) {
|
||||
const pool = pools[mode];
|
||||
if (!pool || !Array.isArray(ids)) continue;
|
||||
const known = ids.filter((id) => pool.allMaps.some((m) => m.id === id));
|
||||
if (known.length >= 2) setActiveMaps(pool, known);
|
||||
}
|
||||
}
|
||||
|
||||
/** Aktive Maps eines Modus setzen und speichern. Gilt für neue Matches, laufende behalten ihre Maps. */
|
||||
saveMapSelection(pools, mode, ids) {
|
||||
const pool = pools[mode];
|
||||
if (!pool) throw new HttpError(400, `Unbekannter Modus: ${mode}`, 'invalid');
|
||||
if (!Array.isArray(ids)) throw new HttpError(400, 'Map-Liste erwartet', 'invalid');
|
||||
const unique = [...new Set(ids.map(String))];
|
||||
const unknown = unique.filter((id) => !pool.allMaps.some((m) => m.id === id));
|
||||
if (unknown.length) throw new HttpError(400, `Unbekannte Map: ${unknown.join(', ')}`, 'invalid');
|
||||
if (unique.length < 2) throw new HttpError(400, 'Mindestens 2 Maps auswählen', 'invalid');
|
||||
setActiveMaps(pool, unique);
|
||||
const saved = this.readJson(MAPS_KEY) ?? {};
|
||||
saved[mode] = pool.maps.map((m) => m.id);
|
||||
this.writeJson(MAPS_KEY, saved);
|
||||
return pool;
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export class DockerServerAdapter {
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** Servername im Steam-Serverbrowser, z. B. "MaxLAN | Team A vs. Team B". */
|
||||
/** Servername im Steam-Serverbrowser, z. B. "Maxlan | Team A vs. Team B". */
|
||||
serverName(matchId, teamNames) {
|
||||
const prefix = this.options.serverNamePrefix ? sanitizeConsoleText(this.options.serverNamePrefix, 24) : '';
|
||||
if (!teamNames) return `${prefix} ${matchId}`.trim();
|
||||
@@ -164,16 +164,16 @@ export class DockerServerAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async launch({ matchId, mode, secrets, teamNames }, blocked = new Set()) {
|
||||
async launch({ matchId, mode, secrets, teamNames, serverName }, blocked = new Set()) {
|
||||
const { port, tvPort } = this.allocatePorts(blocked);
|
||||
const lan = (this.options.lanPorts ?? []).includes(port);
|
||||
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
|
||||
return this.launch({ matchId, mode, secrets, teamNames }, new Set([...blocked, port, tvPort]));
|
||||
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
|
||||
}
|
||||
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
|
||||
const env = {
|
||||
...(this.lanMode ? { CS2_LAN: '1' } : { SRCDS_TOKEN: this.options.gslt, CS2_LAN: '0' }),
|
||||
CS2_SERVERNAME: this.serverName(matchId, teamNames),
|
||||
CS2_SERVERNAME: serverName ? sanitizeConsoleText(serverName, 63) : this.serverName(matchId, teamNames),
|
||||
CS2_PORT: String(port),
|
||||
CS2_RCONPW: secrets.rconPassword,
|
||||
CS2_PW: secrets.serverPassword,
|
||||
@@ -216,7 +216,7 @@ export class DockerServerAdapter {
|
||||
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
|
||||
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
|
||||
await this.docker(['rm', '-f', container]).catch(() => {});
|
||||
return this.launch({ matchId, mode, secrets, teamNames }, new Set([...blocked, port, tvPort]));
|
||||
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
// Messer-Server für die Seitenwahl: Gameserver, die dauerhaft laufen und keinem Match gehören.
|
||||
// Die Teams gehen drauf, spielen ihre Messerrunde und gehen wieder. Nach Spielende oder wenn der Server
|
||||
// leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu.
|
||||
|
||||
import { HttpError } from '../matchService.js';
|
||||
import { randomPassword } from '../security.js';
|
||||
|
||||
const SLOW_POLL_MS = 10_000;
|
||||
// Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet
|
||||
const MAX_RESTARTS = 5;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90 }) {
|
||||
return {
|
||||
map,
|
||||
beforeMapChange: [
|
||||
`game_type ${Number(pool.gameType)}`,
|
||||
`game_mode ${Number(pool.gameMode)}`,
|
||||
'sv_password ""',
|
||||
'tv_enable 0',
|
||||
`changelevel ${map}`,
|
||||
],
|
||||
afterMapChange: [
|
||||
'bot_kick',
|
||||
'bot_quota 0',
|
||||
// Eine Runde entscheidet, danach startet das Spiel von selbst neu (für die nächsten Teams)
|
||||
'mp_maxrounds 1',
|
||||
'mp_match_can_clinch 1',
|
||||
'mp_match_end_restart 1',
|
||||
'mp_match_restart_delay 10',
|
||||
'mp_halftime 0',
|
||||
'mp_overtime_enable 0',
|
||||
// Nur Messer: keine Startwaffen, kein Geld, nichts zu kaufen
|
||||
'mp_ct_default_primary ""',
|
||||
'mp_t_default_primary ""',
|
||||
'mp_ct_default_secondary ""',
|
||||
'mp_t_default_secondary ""',
|
||||
'mp_ct_default_grenades ""',
|
||||
'mp_t_default_grenades ""',
|
||||
'mp_give_player_c4 0',
|
||||
'mp_startmoney 0',
|
||||
'mp_maxmoney 0',
|
||||
'mp_afterroundmoney 0',
|
||||
'mp_buytime 0',
|
||||
'mp_free_armor 1',
|
||||
'mp_weapons_allow_map_placed 0',
|
||||
'mp_death_drop_gun 0',
|
||||
'mp_death_drop_grenade 0',
|
||||
'mp_freezetime 3',
|
||||
'mp_roundtime 2',
|
||||
'mp_roundtime_defuse 2',
|
||||
'mp_roundtime_hostage 2',
|
||||
'mp_autoteambalance 0',
|
||||
'mp_limitteams 0',
|
||||
'mp_autokick 0',
|
||||
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde
|
||||
'mp_do_warmup_period 1',
|
||||
`mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`,
|
||||
'mp_warmup_pausetimer 0',
|
||||
'mp_warmup_start',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 class KnifeServers {
|
||||
constructor({ db, adapter, config, pools = {}, logger = console }) {
|
||||
this.db = db;
|
||||
this.pools = pools;
|
||||
this.adapter = adapter;
|
||||
this.config = config;
|
||||
this.logger = logger;
|
||||
this.busy = new Set();
|
||||
this.lastPoll = new Map();
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
rows() {
|
||||
return this.db.prepare('SELECT * FROM knife_servers ORDER BY number').all();
|
||||
}
|
||||
|
||||
row(id) {
|
||||
return this.db.prepare('SELECT * FROM knife_servers WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
update(id, fields) {
|
||||
const keys = Object.keys(fields);
|
||||
if (!keys.length) return;
|
||||
this.db.prepare(`UPDATE knife_servers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @updated_at WHERE id = @id`)
|
||||
.run({ ...fields, updated_at: Date.now(), id });
|
||||
}
|
||||
|
||||
/** Ansicht für Admin und Startseite. Ohne RCON-Passwort und Handle. */
|
||||
view(row) {
|
||||
const pool = this.pools[row.mode];
|
||||
const size = pool?.teamSize;
|
||||
return {
|
||||
id: row.id,
|
||||
number: row.number,
|
||||
name: row.name,
|
||||
mode: row.mode,
|
||||
modeLabel: pool?.label ?? row.mode,
|
||||
modeShort: size ? `${size}v${size}` : null,
|
||||
map: row.map,
|
||||
mapName: pool?.maps.find((m) => m.id === row.map)?.name ?? row.map,
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
playerCount: row.player_count,
|
||||
restarts: row.restarts,
|
||||
startedAt: row.started_at,
|
||||
connect: row.host && row.port ? `connect ${row.host}:${row.port}` : null,
|
||||
steamUrl: row.host && row.port ? `steam://connect/${row.host}:${row.port}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.rows().map((r) => this.view(r));
|
||||
}
|
||||
|
||||
/** Ports aller Messer-Server auf einem Host (für die Portvergabe zusammen mit den Match-Servern). */
|
||||
usedPorts(host) {
|
||||
const rows = host
|
||||
? this.db.prepare('SELECT host, port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host)
|
||||
: this.db.prepare('SELECT host, port, tv_port FROM knife_servers WHERE port IS NOT NULL').all();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Modi für Messer-Server (5v5, 2v2) mit ihren Maps, aus den Map-Pools. */
|
||||
modes() {
|
||||
return Object.entries(this.pools).map(([id, pool]) => {
|
||||
const maps = pool.maps.map((m) => ({ id: m.id, name: m.name }));
|
||||
return {
|
||||
id,
|
||||
label: pool.label,
|
||||
teamSize: pool.teamSize,
|
||||
maps,
|
||||
defaultMap: maps.some((m) => m.id === this.config.knife.map) ? this.config.knife.map : maps[0]?.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Neuen Messer-Server starten. Liefert die Ansicht des neuen Servers. */
|
||||
async create({ map, mode = 'competitive' }) {
|
||||
if (this.adapter.name === 'static') throw new HttpError(409, 'Mit festen Servern (static) gibt es keine Messer-Server', 'unsupported');
|
||||
const info = this.modes().find((m) => m.id === mode);
|
||||
if (!info) throw new HttpError(400, `Unbekannter Modus: ${mode}`, 'invalid');
|
||||
const chosen = String(map || info.defaultMap);
|
||||
if (!info.maps.some((m) => m.id === chosen)) throw new HttpError(400, `Map ${chosen} gibt es nicht für ${info.label}`, 'invalid');
|
||||
const max = this.config.knife.maxServers;
|
||||
const count = this.db.prepare('SELECT COUNT(*) AS n FROM knife_servers').get().n;
|
||||
if (count >= max) throw new HttpError(409, `Höchstens ${max} Messer-Server`, 'limit');
|
||||
const used = new Set(this.rows().map((r) => r.number));
|
||||
let number = 1;
|
||||
while (used.has(number)) number += 1;
|
||||
const id = `knife${number}`;
|
||||
const now = Date.now();
|
||||
this.db.prepare(`
|
||||
INSERT INTO knife_servers (id, number, name, mode, map, status, message, rcon_password, restarts, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'starting', 'Server wird gestartet', ?, 0, ?, ?)
|
||||
`).run(id, number, `Messer ${number}`, mode, chosen, randomPassword(24), now, now);
|
||||
await this.launch(id);
|
||||
return this.view(this.row(id));
|
||||
}
|
||||
|
||||
async launch(id) {
|
||||
const row = this.row(id);
|
||||
this.busy.add(id);
|
||||
try {
|
||||
const prefix = this.config.docker?.serverNamePrefix;
|
||||
const size = this.pools[row.mode]?.teamSize;
|
||||
const result = await this.adapter.start({
|
||||
matchId: id,
|
||||
// Wingman: 6 Plätze, Competitive: 12 Plätze (wie bei den Match-Servern)
|
||||
mode: row.mode,
|
||||
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
|
||||
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
|
||||
});
|
||||
this.update(id, {
|
||||
status: 'starting',
|
||||
message: 'Container läuft, Spielserver lädt',
|
||||
handle: JSON.stringify(result.handle),
|
||||
host: result.host,
|
||||
port: result.port,
|
||||
tv_port: result.tvPort,
|
||||
player_count: null,
|
||||
started_at: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` });
|
||||
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.message}`, 'start_failed');
|
||||
} finally {
|
||||
this.busy.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async configure(id) {
|
||||
const row = this.row(id);
|
||||
this.busy.add(id);
|
||||
this.update(id, { status: 'configuring', message: `Lade ${row.map}` });
|
||||
try {
|
||||
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map: row.map, pool: this.pools[row.mode], warmupSeconds: this.config.knife.warmupSeconds }));
|
||||
this.update(id, { status: 'running', message: 'Bereit für die Messerrunde', restarts: 0 });
|
||||
} catch (err) {
|
||||
this.update(id, { status: 'config_failed', message: `Konfiguration fehlgeschlagen: ${err.message}` });
|
||||
} finally {
|
||||
this.busy.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async poll() {
|
||||
for (const row of this.rows()) {
|
||||
if (this.busy.has(row.id) || !row.handle) continue;
|
||||
const last = this.lastPoll.get(row.id) ?? 0;
|
||||
if (row.status === 'running' && Date.now() - last < SLOW_POLL_MS) continue;
|
||||
this.lastPoll.set(row.id, Date.now());
|
||||
await this.pollOne(row).catch((err) => this.logger.warn?.(`[messer] ${row.name}: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
async pollOne(row) {
|
||||
let status;
|
||||
try {
|
||||
status = await this.adapter.status(JSON.parse(row.handle));
|
||||
} catch (err) {
|
||||
status = { state: row.status === 'starting' ? 'starting' : 'running', message: err.message };
|
||||
}
|
||||
if (status.state === 'failed' || status.state === 'stopped') {
|
||||
await this.recover(row, status.message);
|
||||
return;
|
||||
}
|
||||
if (status.state === 'running' && row.status === 'starting') {
|
||||
await this.configure(row.id);
|
||||
return;
|
||||
}
|
||||
if (row.status === 'config_failed' && status.state === 'running') {
|
||||
// Konfiguration noch einmal versuchen, der Server läuft ja
|
||||
await this.configure(row.id);
|
||||
return;
|
||||
}
|
||||
if (row.status !== 'running' || status.playerCount === undefined) return;
|
||||
const players = status.playerCount;
|
||||
if (players !== row.player_count) this.update(row.id, { player_count: players });
|
||||
// Alle sind gegangen: Server für die nächsten Teams zurücksetzen
|
||||
if (row.player_count > 0 && players === 0) await this.reset(row.id, 'Server leer – für die nächsten Teams zurückgesetzt');
|
||||
}
|
||||
|
||||
/** Ausgefallenen Server neu starten, damit immer Messer-Server laufen. */
|
||||
async recover(row, reason) {
|
||||
if (row.restarts >= MAX_RESTARTS) {
|
||||
if (row.status !== 'failed') this.update(row.id, { status: 'failed', message: `Ausgefallen, ${MAX_RESTARTS} Neustarts ohne Erfolg: ${reason ?? ''}`.trim() });
|
||||
return;
|
||||
}
|
||||
this.logger.warn?.(`[messer] ${row.name} ausgefallen (${reason}), starte neu`);
|
||||
this.update(row.id, { restarts: row.restarts + 1, status: 'starting', message: `Neustart nach Ausfall (${row.restarts + 1}/${MAX_RESTARTS})` });
|
||||
await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
|
||||
await this.launch(row.id).catch(() => {});
|
||||
}
|
||||
|
||||
async reset(id, message = 'Spiel zurückgesetzt (Admin)') {
|
||||
const row = this.requireRow(id);
|
||||
if (row.status !== 'running') throw new HttpError(409, 'Der Server ist noch nicht bereit', 'wrong_state');
|
||||
try {
|
||||
await this.adapter.sendCommands(JSON.parse(row.handle), KNIFE_RESET_COMMANDS);
|
||||
} catch (err) {
|
||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||
}
|
||||
this.update(id, { message });
|
||||
}
|
||||
|
||||
async startRound(id) {
|
||||
const row = this.requireRow(id);
|
||||
if (row.status !== 'running') throw new HttpError(409, 'Der Server ist noch nicht bereit', 'wrong_state');
|
||||
try {
|
||||
await this.adapter.sendCommands(JSON.parse(row.handle), ['mp_warmup_end']);
|
||||
} catch (err) {
|
||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||
}
|
||||
this.update(id, { message: 'Messerrunde läuft (Admin gestartet)' });
|
||||
}
|
||||
|
||||
/** Server komplett neu starten (neuer Container), z. B. wenn er hängt. */
|
||||
async restart(id) {
|
||||
const row = this.requireRow(id);
|
||||
if (this.busy.has(id)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||||
if (row.handle) await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
|
||||
this.update(id, { restarts: 0, status: 'starting', message: 'Neustart durch Admin' });
|
||||
await this.launch(id);
|
||||
}
|
||||
|
||||
async remove(id) {
|
||||
const row = this.requireRow(id);
|
||||
if (row.handle) await this.adapter.stop(JSON.parse(row.handle)).catch(() => {});
|
||||
this.db.prepare('DELETE FROM knife_servers WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
requireRow(id) {
|
||||
const row = this.row(id);
|
||||
if (!row) throw new HttpError(404, 'Messer-Server nicht gefunden', 'not_found');
|
||||
return row;
|
||||
}
|
||||
|
||||
startLoop() {
|
||||
this.timer = setInterval(() => this.poll().catch((e) => this.logger.error(e)), this.config.statusPollSeconds * 1000);
|
||||
}
|
||||
|
||||
stopLoop() {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
@@ -168,14 +168,20 @@ export class ServerManager {
|
||||
FROM servers s JOIN matches m ON m.id = s.match_id
|
||||
WHERE s.status != 'stopped' AND s.host IS NOT NULL
|
||||
`).all();
|
||||
const knives = this.db.prepare('SELECT id, name, status, host FROM knife_servers WHERE host IS NOT NULL').all();
|
||||
const stats = this.adapter.hostStats();
|
||||
const disks = await Promise.all(stats.map((h) => this.adapter.diskUsage?.(h.name) ?? null));
|
||||
return stats.map((h, i) => ({
|
||||
...h,
|
||||
disk: disks[i],
|
||||
matches: rows
|
||||
matches: [
|
||||
...rows
|
||||
.filter((r) => r.host === h.publicHost)
|
||||
.map((r) => ({ id: r.match_id, title: `${r.team_a_name} vs. ${r.team_b_name}`, status: r.status })),
|
||||
...knives
|
||||
.filter((k) => k.host === h.publicHost)
|
||||
.map((k) => ({ id: k.id, title: `${k.name} (Seitenwahl)`, status: k.status })),
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -258,12 +264,20 @@ export class ServerManager {
|
||||
used.add(r.port);
|
||||
used.add(r.tv_port);
|
||||
}
|
||||
// Messer-Server belegen ihre Ports dauerhaft
|
||||
for (const r of this.db.prepare('SELECT port, tv_port FROM knife_servers WHERE host = ? AND port IS NOT NULL').all(host)) {
|
||||
used.add(r.port);
|
||||
used.add(r.tv_port);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
usedPorts() {
|
||||
const used = new Set();
|
||||
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
|
||||
for (const r of this.db.prepare(`
|
||||
SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL
|
||||
UNION ALL SELECT host, port, tv_port FROM knife_servers WHERE port IS NOT NULL
|
||||
`).all()) {
|
||||
used.add(r.port);
|
||||
used.add(r.tv_port);
|
||||
used.add(`${r.host}:${r.port}`);
|
||||
@@ -578,6 +592,7 @@ export class ServerManager {
|
||||
// ---------- Automatisches Herunterfahren ----------
|
||||
|
||||
async reap(now = Date.now()) {
|
||||
this.service.archiveClosed(now);
|
||||
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
|
||||
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
|
||||
for (const row of rows) {
|
||||
|
||||
@@ -54,6 +54,35 @@ export function validateFlow(flow, mapCount, format) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen Veto-Ablauf für eine beliebige Anzahl Maps. Bans und Picks wechseln sich zwischen den Teams ab:
|
||||
* erst bis zu zwei Bans, dann die Picks (die Seite wählt jeweils das andere Team), dann die restlichen Bans.
|
||||
* Die letzte Map ist die Entscheidungsmap, ihre Seite wählt das Team, das nicht zuletzt dran war.
|
||||
* Liefert null, wenn es für das Format zu wenige Maps gibt.
|
||||
*/
|
||||
export function generateFlow(mapCount, format) {
|
||||
const bestOf = BEST_OF[format];
|
||||
if (!bestOf || mapCount < Math.max(bestOf, 2)) return null;
|
||||
const picks = bestOf - 1;
|
||||
const bans = mapCount - bestOf;
|
||||
const before = Math.min(2, bans);
|
||||
const steps = [];
|
||||
let actor = 'X';
|
||||
const next = () => {
|
||||
const current = actor;
|
||||
actor = actor === 'X' ? 'Y' : 'X';
|
||||
return current;
|
||||
};
|
||||
for (let i = 0; i < before; i += 1) steps.push(`ban:${next()}`);
|
||||
for (let i = 0; i < picks; i += 1) {
|
||||
const picker = next();
|
||||
steps.push(`pick:${picker}`, `side:${picker === 'X' ? 'Y' : 'X'}`);
|
||||
}
|
||||
for (let i = before; i < bans; i += 1) steps.push(`ban:${next()}`);
|
||||
steps.push('decider', `side:${actor}`);
|
||||
return steps;
|
||||
}
|
||||
|
||||
function teamFor(actor, firstTeam) {
|
||||
const second = firstTeam === 'A' ? 'B' : 'A';
|
||||
return actor === 'X' ? firstTeam : second;
|
||||
|
||||
@@ -508,3 +508,185 @@ test('Neustart im laufenden Match: Map wird wieder geladen, Server läuft die vo
|
||||
await close();
|
||||
}
|
||||
});
|
||||
test('Archiv: nur abgeschlossene Matches, eigene Liste, automatisch nach Frist', async () => {
|
||||
const { app, call, close } = await setup({ adminPassword: 'admin-geheim', archiveAfterDays: 3 });
|
||||
try {
|
||||
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
||||
const mk = async (a, b) => (await call('POST', '/matches', { body: { teamA: a, teamB: b, mode: 'wingman', format: 'bo1' } })).body;
|
||||
const open = await mk('Offen A', 'Offen B');
|
||||
const done = await mk('Fertig A', 'Fertig B');
|
||||
const old = await mk('Alt A', 'Alt B');
|
||||
|
||||
// Laufende Matches lassen sich nicht archivieren
|
||||
assert.equal((await call('POST', `/admin/matches/${open.id}/archive`, { token })).status, 409);
|
||||
|
||||
await call('POST', `/admin/matches/${done.id}/finish`, { token });
|
||||
assert.equal((await call('POST', `/admin/matches/${done.id}/archive`, { token })).status, 200);
|
||||
let list = (await call('GET', '/admin/matches', { token })).body;
|
||||
assert.ok(!list.matches.some((m) => m.id === done.id), 'archiviertes Match fehlt in der normalen Liste');
|
||||
assert.equal(list.archivedCount, 1);
|
||||
const archive = (await call('GET', '/admin/matches?archived=1', { token })).body;
|
||||
assert.deepEqual(archive.matches.map((m) => m.id), [done.id]);
|
||||
assert.ok(archive.matches[0].archivedAt > 0);
|
||||
|
||||
// Zurückholen
|
||||
await call('POST', `/admin/matches/${done.id}/unarchive`, { token });
|
||||
list = (await call('GET', '/admin/matches', { token })).body;
|
||||
assert.ok(list.matches.some((m) => m.id === done.id));
|
||||
|
||||
// Automatisch: abgebrochenes Match, älter als 3 Tage
|
||||
await call('POST', `/admin/matches/${old.id}/cancel`, { token });
|
||||
assert.equal(app.service.archiveClosed(Date.now() + 2 * 86_400_000), 0, 'nach 2 Tagen noch nicht');
|
||||
await app.manager.reap(Date.now() + 4 * 86_400_000);
|
||||
assert.ok(app.service.getMatch(old.id).archived_at > 0, 'nach 4 Tagen archiviert');
|
||||
assert.ok(app.service.getMatch(done.id).archived_at > 0, 'zurückgeholtes Match wird nach Ablauf der Frist wieder archiviert');
|
||||
assert.equal(app.service.getMatch(open.id).archived_at, null, 'offene Matches bleiben');
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
test('Messer-Server: nur Admin startet, Startseite sieht laufende, Ports zählen bei der Vergabe mit', async () => {
|
||||
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
|
||||
try {
|
||||
assert.equal((await call('POST', '/admin/knife', { body: {} })).status, 401);
|
||||
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
||||
const info = (await call('GET', '/admin/knife', { token })).body;
|
||||
assert.deepEqual(info.modes.map((m) => m.id).sort(), ['competitive', 'wingman']);
|
||||
const created = await call('POST', '/admin/knife', { token, body: { mode: 'competitive', map: 'de_inferno' } });
|
||||
assert.equal(created.status, 200, JSON.stringify(created.body));
|
||||
|
||||
// Noch nicht bereit: Startseite zeigt ihn nicht
|
||||
assert.equal((await call('GET', '/knife-servers')).body.servers.length, 0);
|
||||
await app.knife.poll();
|
||||
await waitFor(() => app.knife.row(created.body.id).status === 'running', { label: 'Messer-Server bereit' });
|
||||
const pub = (await call('GET', '/knife-servers')).body.servers;
|
||||
assert.equal(pub.length, 1);
|
||||
assert.equal(pub[0].map, 'de_inferno');
|
||||
assert.ok(!JSON.stringify(pub).includes(app.knife.row(created.body.id).rcon_password));
|
||||
|
||||
// Ports des Messer-Servers sind für Matches belegt
|
||||
const row = app.knife.row(created.body.id);
|
||||
assert.ok(app.manager.usedPortsOn(row.host).has(row.port));
|
||||
|
||||
assert.equal((await call('DELETE', `/admin/knife/${created.body.id}`, { token })).status, 200);
|
||||
assert.equal(app.knife.list().length, 0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
test('Map-Pools im Admin: Auswahl gilt für neue Matches, Veto passt sich an, laufende Matches behalten ihre Maps', async () => {
|
||||
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
|
||||
try {
|
||||
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
||||
const before = (await call('POST', '/matches', { body: { teamA: 'Vorher A', teamB: 'Vorher B', mode: 'competitive', format: 'bo3' } })).body;
|
||||
const view = (await call('GET', '/admin/map-pools', { token })).body;
|
||||
const comp = view.pools.find((p) => p.mode === 'competitive');
|
||||
assert.ok(['de_overpass', 'de_vertigo', 'de_cache'].every((id) => comp.maps.some((m) => m.id === id && m.active)));
|
||||
|
||||
// Nur 5 Maps: BO5 geht noch, BO3 hat dann 2 Bans
|
||||
const five = ['de_cache', 'de_dust2', 'de_inferno', 'de_mirage', 'de_nuke'];
|
||||
const saved = await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: five } });
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||
const pool = saved.body.pools.find((p) => p.mode === 'competitive');
|
||||
assert.equal(pool.maps.filter((m) => m.active).length, 5);
|
||||
assert.deepEqual(pool.formats.bo3.flow, ['ban:X', 'ban:Y', 'pick:X', 'side:Y', 'pick:Y', 'side:X', 'decider', 'side:X']);
|
||||
|
||||
// Neue Matches bekommen die neue Auswahl, das alte behält seine 10 Maps
|
||||
const after = (await call('POST', '/matches', { body: { teamA: 'Nachher A', teamB: 'Nachher B', mode: 'competitive', format: 'bo3' } })).body;
|
||||
assert.equal(JSON.parse(app.service.getMatch(after.id).maps).length, 5);
|
||||
assert.equal(JSON.parse(app.service.getMatch(before.id).maps).length, 10);
|
||||
// Öffentliche Konfiguration (Match erstellen) zeigt die neuen Formate
|
||||
const cfg = (await call('GET', '/config')).body;
|
||||
assert.equal(cfg.pools.find((p) => p.mode === 'competitive').maps.length, 5);
|
||||
|
||||
// Ungültig: zu wenige oder unbekannte Maps
|
||||
assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache'] } })).status, 400);
|
||||
assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache', 'de_foo'] } })).status, 400);
|
||||
assert.equal((await call('PUT', '/admin/map-pools', { body: { mode: 'competitive', maps: five } })).status, 401);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
test('Seitenwahl per Messerrunde: Organisator legt fest, wer das Veto beginnt', async () => {
|
||||
const { app, call, close } = await setup();
|
||||
try {
|
||||
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo3', vetoStart: 'knife' } });
|
||||
assert.equal(created.status, 200, JSON.stringify(created.body));
|
||||
const { id, organizerToken: org } = created.body;
|
||||
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||
assert.equal(snap.vetoStart, 'knife');
|
||||
const codes = snap.organizer.codes;
|
||||
for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) {
|
||||
const r = await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } });
|
||||
await call('POST', `/matches/${id}/ready`, { token: r.body.playerToken, body: { ready: true } });
|
||||
}
|
||||
// Ohne Angabe, wer beginnt, startet das Veto nicht
|
||||
const missing = await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||
assert.equal(missing.status, 400);
|
||||
assert.equal(missing.body.code, 'first_team_required');
|
||||
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'X' } })).status, 400);
|
||||
|
||||
const ok = await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'B' } });
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||
const after = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||
assert.equal(after.status, 'veto');
|
||||
assert.equal(after.veto.current.team, 'B', 'das gewählte Team beginnt');
|
||||
assert.ok(app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND message LIKE '%Messerrunde%'").get(id));
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Ohne Messerrunde bleibt firstTeam wirkungslos', async () => {
|
||||
const { call, close } = await setup();
|
||||
try {
|
||||
const { id, org, tokens } = await readyWingmanMatch(call, 'bo1');
|
||||
await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'B' } });
|
||||
const snap = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body;
|
||||
assert.equal(snap.veto.current.team, 'A', 'feste Startregel A gilt');
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
test('Chat: Match-Chat für beide Teams und Organisator, Team-Chat nur fürs eigene Team, Zuschauer sehen nichts', async () => {
|
||||
const { call, close } = await setup();
|
||||
try {
|
||||
const { id, org, codes, tokens } = await readyWingmanMatch(call, 'bo1');
|
||||
const spectator = codes.spectator;
|
||||
const post = (token, channel, text) => call('POST', `/matches/${id}/chat`, { token, body: { channel, text } });
|
||||
|
||||
assert.equal((await post(tokens.a1, 'match', 'Hallo zusammen')).status, 200);
|
||||
assert.equal((await post(tokens.a1, 'A', 'Wir bannen Nuke')).status, 200);
|
||||
assert.equal((await post(org, 'match', 'Veto startet gleich')).status, 200);
|
||||
// Fremder Team-Chat und Zuschauer: verboten
|
||||
assert.equal((await post(tokens.b1, 'A', 'Spioniere')).status, 403);
|
||||
assert.equal((await post(org, 'A', 'Organisator im Team-Chat')).status, 403);
|
||||
assert.equal((await post(spectator, 'match', 'Zuschauer')).status, 403);
|
||||
assert.equal((await post(tokens.a1, 'match', ' ')).status, 400);
|
||||
assert.equal((await post(tokens.a1, 'match', 'x'.repeat(301))).status, 400);
|
||||
|
||||
const a = (await call('GET', `/matches/${id}`, { token: tokens.a2 })).body.chat;
|
||||
assert.deepEqual(a.channels.map((c) => c.key), ['match', 'A']);
|
||||
assert.deepEqual(a.messages.match.map((m) => m.text), ['Hallo zusammen', 'Veto startet gleich']);
|
||||
assert.equal(a.messages.match[1].organizer, true);
|
||||
assert.deepEqual(a.messages.A.map((m) => m.text), ['Wir bannen Nuke']);
|
||||
|
||||
const b = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.chat;
|
||||
assert.deepEqual(b.channels.map((c) => c.key), ['match', 'B']);
|
||||
assert.ok(!JSON.stringify(b).includes('Wir bannen Nuke'), 'Team B sieht den Team-Chat von A nicht');
|
||||
assert.deepEqual((await call('GET', `/matches/${id}`, { token: org })).body.chat.channels.map((c) => c.key), ['match']);
|
||||
assert.equal((await call('GET', `/matches/${id}`, { token: spectator })).body.chat, null);
|
||||
assert.equal((await call('GET', `/matches/${id}`)).body.chat, null);
|
||||
|
||||
// Spam-Schutz und nach Matchende nur lesbar
|
||||
for (let i = 0; i < 5; i++) await post(tokens.b1, 'match', `Nachricht ${i}`);
|
||||
assert.equal((await post(tokens.b1, 'match', 'eine zu viel')).status, 429);
|
||||
await call('POST', `/matches/${id}/finish`, { token: org });
|
||||
assert.equal((await post(tokens.a1, 'match', 'Nach dem Ende')).status, 409);
|
||||
const closed = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.chat;
|
||||
assert.equal(closed.canWrite, false);
|
||||
assert.equal(closed.messages.match.length, 7);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { openDatabase } from '../src/db.js';
|
||||
import { buildKnifeCommands, KnifeServers } from '../src/servers/knifeServers.js';
|
||||
|
||||
const pools = {
|
||||
competitive: { label: 'Competitive 5 gegen 5', gameType: 0, gameMode: 1, teamSize: 5, maps: [{ id: 'de_mirage', name: 'Mirage' }, { id: 'de_inferno', name: 'Inferno' }] },
|
||||
wingman: { label: 'Wingman 2 gegen 2', gameType: 0, gameMode: 2, teamSize: 2, maps: [{ id: 'de_vertigo', name: 'Vertigo' }] },
|
||||
};
|
||||
const quiet = { log() {}, warn() {}, error() {} };
|
||||
|
||||
/** Adapter, dessen Zustand der Test steuert. */
|
||||
function fakeAdapter() {
|
||||
const a = {
|
||||
name: 'docker',
|
||||
starts: [],
|
||||
configures: [],
|
||||
commands: [],
|
||||
stops: 0,
|
||||
state: 'starting',
|
||||
players: null,
|
||||
async start(args) {
|
||||
a.starts.push(args);
|
||||
return { handle: { container: `cs2mv-${args.matchId}`, n: a.starts.length }, host: '10.0.0.5', port: 27015 + a.starts.length - 1, tvPort: 27115 };
|
||||
},
|
||||
async status() { return { state: a.state, playerCount: a.players }; },
|
||||
async configure(handle, plan) { a.configures.push(plan); },
|
||||
async sendCommands(handle, cmds) { a.commands.push(...cmds); },
|
||||
async stop() { a.stops += 1; },
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const config = loadConfig({ knife: { map: 'de_mirage', warmupSeconds: 60, maxServers: 2 }, docker: { serverNamePrefix: 'MaxLAN' } });
|
||||
const adapter = fakeAdapter();
|
||||
const knife = new KnifeServers({ db: openDatabase(':memory:'), adapter, config, pools, logger: quiet });
|
||||
return { knife, adapter };
|
||||
}
|
||||
const poll = async (knife) => { knife.lastPoll.clear(); await knife.poll(); };
|
||||
|
||||
test('Messerrunde: nur Messer, eine Runde, danach automatisch neues Spiel', () => {
|
||||
const plan = buildKnifeCommands({ map: 'de_inferno', warmupSeconds: 45 });
|
||||
assert.equal(plan.beforeMapChange.at(-1), 'changelevel de_inferno');
|
||||
assert.ok(plan.beforeMapChange.includes('sv_password ""'), 'offen, ohne Passwort');
|
||||
for (const c of ['mp_maxrounds 1', 'mp_match_end_restart 1', 'mp_ct_default_secondary ""', 'mp_startmoney 0', 'mp_warmuptime 45']) {
|
||||
assert.ok(plan.afterMapChange.includes(c), `${c} fehlt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Start, Konfiguration und Ansicht ohne Geheimnisse', async () => {
|
||||
const { knife, adapter } = setup();
|
||||
const created = await knife.create({});
|
||||
assert.equal(created.name, 'Messer 1');
|
||||
assert.equal(created.status, 'starting');
|
||||
assert.equal(adapter.starts[0].serverName, 'MaxLAN | Messer 1 5v5 (Seitenwahl)');
|
||||
assert.equal(adapter.starts[0].mode, 'competitive');
|
||||
assert.equal(adapter.starts[0].secrets.serverPassword, '');
|
||||
|
||||
adapter.state = 'running';
|
||||
await poll(knife);
|
||||
assert.equal(knife.row('knife1').status, 'running');
|
||||
assert.equal(adapter.configures[0].map, 'de_mirage');
|
||||
|
||||
const [view] = knife.list();
|
||||
assert.equal(view.connect, 'connect 10.0.0.5:27015');
|
||||
assert.ok(!JSON.stringify(view).includes(knife.row('knife1').rcon_password), 'kein RCON-Passwort in der Ansicht');
|
||||
});
|
||||
|
||||
test('Wingman 2v2: eigener Spielmodus, Maps nur aus dem Wingman-Pool', async () => {
|
||||
const { knife, adapter } = setup();
|
||||
const k = await knife.create({ mode: 'wingman' });
|
||||
assert.equal(k.modeShort, '2v2');
|
||||
assert.equal(k.map, 'de_vertigo', 'Standard-Map gibt es im Wingman-Pool nicht, also die erste');
|
||||
assert.equal(adapter.starts[0].mode, 'wingman');
|
||||
assert.equal(adapter.starts[0].serverName, 'MaxLAN | Messer 1 2v2 (Seitenwahl)');
|
||||
adapter.state = 'running';
|
||||
await poll(knife);
|
||||
assert.ok(adapter.configures[0].beforeMapChange.includes('game_mode 2'));
|
||||
await assert.rejects(knife.create({ mode: 'wingman', map: 'de_mirage' }), /gibt es nicht für Wingman/);
|
||||
await assert.rejects(knife.create({ mode: 'deathmatch' }), /Unbekannter Modus/);
|
||||
});
|
||||
|
||||
test('Grenzen: nur Maps aus den Pools, höchstens maxServers', async () => {
|
||||
const { knife } = setup();
|
||||
await assert.rejects(knife.create({ map: 'de_unbekannt' }), /gibt es nicht/);
|
||||
await knife.create({ map: 'de_inferno' });
|
||||
await knife.create({});
|
||||
await assert.rejects(knife.create({}), /Höchstens 2/);
|
||||
// Freie Nummer wird wiederverwendet
|
||||
await knife.remove('knife1');
|
||||
assert.equal((await knife.create({})).name, 'Messer 1');
|
||||
});
|
||||
|
||||
test('Teams gehen runter: Server setzt sich für die nächsten zurück', async () => {
|
||||
const { knife, adapter } = setup();
|
||||
await knife.create({});
|
||||
adapter.state = 'running';
|
||||
await poll(knife);
|
||||
adapter.players = 4;
|
||||
await poll(knife);
|
||||
assert.equal(adapter.commands.length, 0);
|
||||
adapter.players = 0;
|
||||
await poll(knife);
|
||||
assert.deepEqual(adapter.commands, ['mp_warmup_start']);
|
||||
assert.match(knife.row('knife1').message, /zurückgesetzt/);
|
||||
});
|
||||
|
||||
test('Ausfall: Server startet neu, nach zu vielen Ausfällen gibt die App auf', async () => {
|
||||
const { knife, adapter } = setup();
|
||||
await knife.create({});
|
||||
adapter.state = 'failed';
|
||||
await poll(knife);
|
||||
assert.equal(adapter.starts.length, 2, 'nach Ausfall neu gestartet');
|
||||
assert.equal(knife.row('knife1').restarts, 1);
|
||||
for (let i = 0; i < 6; i++) await poll(knife);
|
||||
assert.equal(adapter.starts.length, 6, 'höchstens 5 Neustarts');
|
||||
assert.equal(knife.row('knife1').status, 'failed');
|
||||
// Neustart durch den Admin setzt den Zähler zurück
|
||||
adapter.state = 'starting';
|
||||
await knife.restart('knife1');
|
||||
assert.equal(knife.row('knife1').restarts, 0);
|
||||
assert.equal(adapter.starts.length, 7);
|
||||
});
|
||||
@@ -2,11 +2,14 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { computeVetoState, resolveAction, validateFlow } from '../src/veto.js';
|
||||
import { buildMapPools, loadMapPools } from '../src/mapPools.js';
|
||||
import { computeVetoState, generateFlow, resolveAction, validateFlow } from '../src/veto.js';
|
||||
import { buildMapPools, loadMapPools, setActiveMaps } from '../src/mapPools.js';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pools = loadMapPools(path.join(here, '..', 'config', 'map-pools.json'));
|
||||
// Die klassischen 7 Competitive-Maps: dafür gilt der fest hinterlegte Ablauf
|
||||
const SEVEN = ['de_ancient', 'de_anubis', 'de_dust2', 'de_inferno', 'de_mirage', 'de_nuke', 'de_train'];
|
||||
const competitive7 = () => setActiveMaps(structuredClone(pools.competitive), SEVEN);
|
||||
|
||||
test('alle ausgelieferten Abläufe sind gültig, Wingman-BO5 ist nicht unterstützt', () => {
|
||||
assert.equal(pools.competitive.formats.bo1.supported, true);
|
||||
@@ -37,8 +40,42 @@ test('ungültiger Ablauf in der Konfiguration deaktiviert nur dieses Format', ()
|
||||
},
|
||||
});
|
||||
assert.equal(custom.mini.formats.bo1.supported, true);
|
||||
assert.equal(custom.mini.formats.bo3.supported, false);
|
||||
assert.match(custom.mini.formats.bo5.reason, /Kein Veto-Ablauf/);
|
||||
assert.equal(custom.mini.formats.bo1.generated, false, 'passender fester Ablauf wird genommen');
|
||||
// Ungültiger fester Ablauf: die Anwendung erzeugt einen passenden
|
||||
assert.equal(custom.mini.formats.bo3.supported, true);
|
||||
assert.equal(custom.mini.formats.bo3.generated, true);
|
||||
assert.match(custom.mini.formats.bo5.reason, /mindestens 5 Maps/);
|
||||
});
|
||||
|
||||
test('Erzeugte Abläufe sind für jede Map-Anzahl gültig', () => {
|
||||
for (let n = 2; n <= 12; n += 1) {
|
||||
for (const format of ['bo1', 'bo3', 'bo5']) {
|
||||
const flow = generateFlow(n, format);
|
||||
const need = { bo1: 2, bo3: 3, bo5: 5 }[format];
|
||||
if (n < need) {
|
||||
assert.equal(flow, null, `${format} mit ${n} Maps`);
|
||||
continue;
|
||||
}
|
||||
assert.deepEqual(validateFlow(flow, n, format), [], `${format} mit ${n} Maps: ${flow.join(' ')}`);
|
||||
}
|
||||
}
|
||||
// Bans und Picks wechseln sich ab, die Seite der Entscheidungsmap wählt das Team, das nicht zuletzt dran war
|
||||
assert.deepEqual(generateFlow(5, 'bo3'), ['ban:X', 'ban:Y', 'pick:X', 'side:Y', 'pick:Y', 'side:X', 'decider', 'side:X']);
|
||||
assert.deepEqual(generateFlow(3, 'bo1'), ['ban:X', 'ban:Y', 'decider', 'side:X']);
|
||||
});
|
||||
|
||||
test('Aktive Maps wählen: Formate passen sich an, Reihenfolge bleibt wie im Katalog', () => {
|
||||
const pool = structuredClone(pools.competitive);
|
||||
setActiveMaps(pool, ['de_vertigo', 'de_cache', 'de_mirage', 'de_unbekannt']);
|
||||
assert.deepEqual(pool.maps.map((m) => m.id), ['de_cache', 'de_mirage', 'de_vertigo']);
|
||||
assert.equal(pool.formats.bo1.supported, true);
|
||||
assert.equal(pool.formats.bo3.supported, true);
|
||||
assert.equal(pool.formats.bo5.supported, false);
|
||||
// 10 Maps (alle): kein fester Ablauf passt, es wird erzeugt
|
||||
assert.equal(pools.competitive.maps.length, 10);
|
||||
assert.equal(pools.competitive.formats.bo3.generated, true);
|
||||
// 7 klassische Maps: fester Ablauf aus der Konfiguration
|
||||
assert.equal(competitive7().formats.bo3.generated, false);
|
||||
});
|
||||
|
||||
function play(pool, format, firstTeam, choices) {
|
||||
@@ -52,7 +89,7 @@ function play(pool, format, firstTeam, choices) {
|
||||
}
|
||||
|
||||
test('BO3 Competitive: kompletter Ablauf mit Seitenwahl und Entscheidungsmap', () => {
|
||||
const state = play(pools.competitive, 'bo3', 'B', [
|
||||
const state = play(competitive7(), 'bo3', 'B', [
|
||||
{ map: 'de_train' }, // B bannt
|
||||
{ map: 'de_nuke' }, // A bannt
|
||||
{ map: 'de_mirage' }, // B pickt
|
||||
@@ -82,7 +119,7 @@ test('BO1 Wingman: Entscheidungsmap wird automatisch übernommen', () => {
|
||||
});
|
||||
|
||||
test('falsches Team, veraltete Schritte und vergebene Maps werden abgelehnt', () => {
|
||||
const pool = pools.competitive;
|
||||
const pool = competitive7();
|
||||
const ctx = { maps: pool.maps, flow: pool.formats.bo1.flow, firstTeam: 'A', actions: [] };
|
||||
assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 0, map: 'de_nuke' }), { code: 'not_your_turn' });
|
||||
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 1, map: 'de_nuke' }), { code: 'stale_step' });
|
||||
|
||||
Reference in new issue
Block a user