Compare commits
40
Commits
7b6ce01d05
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7272d09f | ||
|
|
e8e070dfe6 | ||
|
|
123c2f11d7 | ||
|
|
4bce16d084 | ||
|
|
1fddf4d20c | ||
|
|
52c07c88b8 | ||
|
|
69fcf6d15a | ||
|
|
ece21db78a | ||
|
|
7e17a33c3f | ||
|
|
e384279491 | ||
|
|
da54150df6 | ||
|
|
ea37c23350 | ||
|
|
48241f0784 | ||
|
|
e18b8b4430 | ||
|
|
35bdfc1f67 | ||
|
|
f64da5ac86 | ||
|
|
141c5597c4 | ||
|
|
bd022e043e | ||
|
|
5503995791 | ||
|
|
be42beb260 | ||
|
|
cc6e7f65be | ||
|
|
1492c280d5 | ||
|
|
934d989572 | ||
|
|
dc601a32fe | ||
|
|
6a922f80de | ||
|
|
d36dcacd39 | ||
|
|
148cd99856 | ||
|
|
e18a6cc0c9 | ||
|
|
13320313ec | ||
|
|
3c5a9b4162 | ||
|
|
2c57b8c170 | ||
|
|
7fe4c2ec79 | ||
|
|
ee9c488a7e | ||
|
|
af2ecf7a3a | ||
|
|
fb96fb5d08 | ||
|
|
c820ba9f12 | ||
|
|
e26c736f2e | ||
|
|
a3471a2f3c | ||
|
|
cdf41df01c | ||
|
|
c62fe4ba1e |
No files matched your search
@@ -21,6 +21,8 @@ TRUST_PROXY=false
|
|||||||
SERVER_ADAPTER=demo
|
SERVER_ADAPTER=demo
|
||||||
SERVER_IDLE_TIMEOUT_MINUTES=30
|
SERVER_IDLE_TIMEOUT_MINUTES=30
|
||||||
SERVER_MAX_LIFETIME_HOURS=8
|
SERVER_MAX_LIFETIME_HOURS=8
|
||||||
|
# Offene Lobbys nach so vielen Stunden samt Server löschen (0 = nie)
|
||||||
|
LOBBY_MAX_HOURS=3
|
||||||
SERVER_STATUS_POLL_SECONDS=5
|
SERVER_STATUS_POLL_SECONDS=5
|
||||||
SERVER_REAPER_INTERVAL_SECONDS=60
|
SERVER_REAPER_INTERVAL_SECONDS=60
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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
|
### Wiederherstellung nach Neuladen oder Verbindungsabbruch
|
||||||
|
|
||||||
Der gesamte Zustand liegt in SQLite. Der Browser speichert pro Match nur seinen Zugangstoken (`localStorage`).
|
Der gesamte Zustand liegt in SQLite. Der Browser speichert pro Match nur seinen Zugangstoken (`localStorage`).
|
||||||
@@ -117,6 +126,7 @@ Alle `SERVER_REAPER_INTERVAL_SECONDS` prüft das Backend alle aktiven Server:
|
|||||||
- **Lobby, Veto oder Server ohne Spielerzahl:** Ist niemand mit der Match-Seite verbunden und gab es länger als `SERVER_IDLE_TIMEOUT_MINUTES` keine Aktivität, wird der Server gestoppt.
|
- **Lobby, Veto oder Server ohne Spielerzahl:** Ist niemand mit der Match-Seite verbunden und gab es länger als `SERVER_IDLE_TIMEOUT_MINUTES` keine Aktivität, wird der Server gestoppt.
|
||||||
- **Konfigurierter echter Server:** Gestoppt wird, wenn länger als die Frist keine Spieler auf dem Server waren. Die Spielerzahl kommt per RCON aus `status`.
|
- **Konfigurierter echter Server:** Gestoppt wird, wenn länger als die Frist keine Spieler auf dem Server waren. Die Spielerzahl kommt per RCON aus `status`.
|
||||||
- **Unabhängig davon:** Nach `SERVER_MAX_LIFETIME_HOURS` wird jeder Server gestoppt.
|
- **Unabhängig davon:** Nach `SERVER_MAX_LIFETIME_HOURS` wird jeder Server gestoppt.
|
||||||
|
- **Alte Lobbys:** Eine Lobby, deren Veto nach `LOBBY_MAX_HOURS` (Standard 3 Stunden) seit dem Erstellen nicht gestartet wurde, wird samt Server gelöscht.
|
||||||
|
|
||||||
Ein gestoppter Server lässt sich vom Organisator neu starten.
|
Ein gestoppter Server lässt sich vom Organisator neu starten.
|
||||||
|
|
||||||
@@ -274,11 +284,12 @@ Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen au
|
|||||||
Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder.
|
Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder.
|
||||||
|
|
||||||
- Modus wählbar: **Competitive 5v5** (12 Plätze, Maps aus dem Competitive-Pool) oder **Wingman 2v2** (6 Plätze, Maps aus dem Wingman-Pool).
|
- Modus wählbar: **Competitive 5v5** (12 Plätze, Maps aus dem Competitive-Pool) oder **Wingman 2v2** (6 Plätze, Maps aus dem Wingman-Pool).
|
||||||
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`) und auf der Startseite mit „Verbinden“.
|
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`). Sie dienen als Ausweichmöglichkeit; Matches mit Messerrunde nutzen ihren eigenen Server (siehe unten).
|
||||||
- Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (`KNIFE_WARMUP_SECONDS`, Standard 90). Nach der Runde startet das Spiel von selbst neu (`mp_match_end_restart`). Wird der Server leer, setzt die App ihn zusätzlich zurück.
|
- Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (`KNIFE_WARMUP_SECONDS`, Standard 90). Nach der Runde startet das Spiel von selbst neu (`mp_match_end_restart`). Wird der Server leer, setzt die App ihn zusätzlich zurück.
|
||||||
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
|
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
|
||||||
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
|
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
|
||||||
- Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches.
|
- Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches.
|
||||||
|
- **Im Match:** Wählt der Organisator beim Erstellen „Wer beginnt das Veto?“ → **Messerrunde**, läuft die Messerrunde auf dem **eigenen Server des Matches**: Sobald er hochgefahren ist, richtet die App dort eine Messerrunde mit dem Passwort des Matches ein. Die Lobby zeigt den Spielern beider Teams die Verbindungsdaten („In CS2 verbinden“). Die Aufwärmphase läuft ohne Ende, bis der Organisator in der Lobby **Messerrunde starten** klickt; mit **Runde wiederholen** geht es zurück in die Aufwärmphase. Danach klickt der Organisator statt „Veto starten“ auf „Team A beginnt“ bzw. „Team B beginnt“. Nach dem Veto richtet die App denselben Server für das Match ein und nimmt die Messer-Einstellungen zurück, die Spieler bleiben einfach drauf. Ohne eigenen Server (z. B. Adapter `static` ohne freien Server) zeigt die Lobby wie bisher die freien Messer-Server. Im Admin-Bereich gibt es die Knöpfe „Team A/B beginnt“ auch in der Matchkarte.
|
||||||
- Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
|
- Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
|
||||||
|
|
||||||
### Server im Steam-Serverbrowser (LAN-Reiter)
|
### Server im Steam-Serverbrowser (LAN-Reiter)
|
||||||
@@ -383,7 +394,15 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
|
|||||||
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
|
- **Unten:** offene Lobbys und die Ergebnisse der letzten drei Stunden.
|
||||||
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
|
- **Datenschutz:** Codes und Verbindungsdaten erscheinen nicht.
|
||||||
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
|
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
|
||||||
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
|
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls. Der Organisator bekommt im Link „Match-Übersicht“ nicht den globalen Schlüssel, sondern einen eigenen, der nur sein Match zeigt.
|
||||||
|
|
||||||
|
### Beamer-Diashow
|
||||||
|
|
||||||
|
Unter `/beamer` (in der Kopfzeile verlinkt) läuft eine Diashow für den Beamer: ein laufendes Match pro Folie, groß und aus der Ferne lesbar, mit Teams, Serienstand, aktueller Map samt Rundenstand und Seiten sowie den Maps der Serie. Matches im Veto zeigen, wer gerade dran ist. Nach den Matches folgt eine Folie mit offenen Lobbys und Ergebnissen.
|
||||||
|
|
||||||
|
- **Weiterschalten:** alle 15 Sekunden, einstellbar mit `?sekunden=20` (5 bis 120). Ein roter Balken unten zeigt die Zeit bis zur nächsten Folie.
|
||||||
|
- **Bedienung:** Klick schaltet Vollbild um, `F` ebenso, `←`/`→` blättern, Leertaste hält an.
|
||||||
|
- **Schlüssel:** Mit `STREAM_KEY` als `/beamer?key=<schlüssel>` aufrufen, wie bei der Stream-Übersicht.
|
||||||
|
|
||||||
### Stream-Übersicht eines Matches
|
### Stream-Übersicht eines Matches
|
||||||
|
|
||||||
@@ -462,6 +481,7 @@ Unter `/admin` mit dem Passwort aus `ADMIN_PASSWORD` (leer = Admin-Bereich aus).
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
- **Konsole:** Knopf „Konsole“ in der Matchkarte und bei jedem Messer-Server. Zeigt die Ausgabe des Containers live (`docker logs --follow`, die letzten 300 Zeilen und alles Neue), mit Filter, „Mitlaufen“, „Leeren“ und „Neu verbinden“. Passwörter des Servers sind ausgeblendet. Höchstens 8 Konsolen gleichzeitig.
|
||||||
- **Details:** Einladungslinks und Codes (erneuerbar), Verbindungsbefehle für Spieler und CSTV, Spielerliste mit „Entfernen“, Rollback der laufenden Map.
|
- **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“.
|
- **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“.
|
||||||
|
|
||||||
@@ -469,13 +489,14 @@ Startet man den Server eines laufenden Matches neu, lädt die Anwendung danach d
|
|||||||
|
|
||||||
### Reiter „Einstellungen“
|
### Reiter „Einstellungen“
|
||||||
|
|
||||||
Die Einstellungen sind in eine Seitenleiste gegliedert: **Veto** (Map-Pools), **Gameserver** (Match-Regeln, CSTV, Zeiten, Messer-Server), **Server-Betrieb** (Docker-Hosts und Image-Update) und **Benachrichtigungen** (Discord). Am Handy stehen die Menüpunkte als Knöpfe über dem Inhalt. Der zuletzt gewählte Punkt bleibt im Browser gespeichert.
|
Die Einstellungen sind in eine Seitenleiste gegliedert: **Veto** (Map-Pools), **Gameserver** (Match-Regeln, CSTV, Zeiten, Messer-Server), **Server-Betrieb** (Docker-Hosts, Image- und Spieldateien-Update) und **Benachrichtigungen** (Discord). Am Handy stehen die Menüpunkte als Knöpfe über dem Inhalt. Der zuletzt gewählte Punkt bleibt im Browser gespeichert.
|
||||||
|
|
||||||
- **Messer-Server:** starten (Modus 5v5 oder 2v2 und Map wählen), Runde starten, zurücksetzen, neu starten, entfernen. Siehe „Messer-Server für die Seitenwahl“.
|
- **Messer-Server:** starten (Modus 5v5 oder 2v2 und Map wählen), Runde starten, zurücksetzen, neu starten, entfernen. Siehe „Messer-Server für die Seitenwahl“.
|
||||||
- **Map-Pools:** pro Modus wählen, welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an, siehe „Maps im Admin-Bereich wählen“.
|
- **Map-Pools:** pro Modus wählen, welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an, siehe „Maps im Admin-Bereich wählen“.
|
||||||
- **Gameserver:** Runden Competitive und Wingman, Verlängerung, zusätzliche Konsolenbefehle, CSTV an/aus und Verzögerung, Zeit pro Veto-Schritt, Leerlauf-Frist, maximale Laufzeit, Startzeit (inklusive Download) und Archiv-Frist. Die Werte aus der `.env` sind die Vorgabe. Was hier geändert wird, steht in der Datenbank und gilt auch nach einem Neustart. Mit „live“ markierte Werte gehen beim Speichern sofort per RCON an alle laufenden Server. Befehle, die Passwörter ändern oder den Server beenden, sind gesperrt.
|
- **Gameserver:** Runden Competitive und Wingman, Verlängerung, zusätzliche Konsolenbefehle, CSTV an/aus und Verzögerung, Zeit pro Veto-Schritt, Leerlauf-Frist, maximale Laufzeit, Startzeit (inklusive Download) und Archiv-Frist. Die Werte aus der `.env` sind die Vorgabe. Was hier geändert wird, steht in der Datenbank und gilt auch nach einem Neustart. Mit „live“ markierte Werte gehen beim Speichern sofort per RCON an alle laufenden Server. Befehle, die Passwörter ändern oder den Server beenden, sind gesperrt.
|
||||||
- **Docker-Hosts:** oben die Auslastung pro Host (belegte und freie Plätze, freier Speicher dort, wo das Gameserver-Volume liegt, Matches und Messer-Server darauf), darunter Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt `CS2_DOCKER_HOSTS`. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern.
|
- **Docker-Hosts:** oben die Auslastung pro Host (belegte und freie Plätze, freier Speicher dort, wo das Gameserver-Volume liegt, Matches und Messer-Server darauf), darunter Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt `CS2_DOCKER_HOSTS`. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern.
|
||||||
- **Image aktualisieren:** lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter.
|
- **Image aktualisieren:** lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter.
|
||||||
|
- **Spieldateien aktualisieren:** Holt das neueste CS2-Update per SteamCMD in das Spieldateien-Volume jedes Hosts, in einem kurzlebigen Container (`cs2mv-update-<host>`). Das Image-Update allein bringt keine neuen Spieldateien. Der Knopf zeigt den Fortschritt („Update läuft 43 %“), darunter steht pro Host das Ergebnis, z. B. „bereits aktuell (Build 26334290)“. Hosts mit laufenden Servern (Matches oder Messer-Server) werden ausgelassen, außer man setzt den Haken „auch Hosts mit laufenden Servern“. Laufende Server nutzen die neuen Dateien erst nach einem Neustart.
|
||||||
- **Discord-Meldungen:** welche Ereignisse gemeldet werden, plus Testmeldung.
|
- **Discord-Meldungen:** welche Ereignisse gemeldet werden, plus Testmeldung.
|
||||||
|
|
||||||
## Umgebungsvariablen
|
## Umgebungsvariablen
|
||||||
@@ -492,6 +513,7 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
|||||||
| `SERVER_ADAPTER` | `demo` | `demo`, `docker` oder `static` |
|
| `SERVER_ADAPTER` | `demo` | `demo`, `docker` oder `static` |
|
||||||
| `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren |
|
| `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren |
|
||||||
| `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server |
|
| `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server |
|
||||||
|
| `LOBBY_MAX_HOURS` | `3` | offene Lobbys nach so vielen Stunden samt Server löschen; `0` = nie |
|
||||||
| `ADMIN_PASSWORD` | leer | Passwort für den Admin-Bereich `/admin`; leer = Admin-Bereich aus |
|
| `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 |
|
| `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 |
|
| `ARCHIVE_AFTER_DAYS` | `3` | abgeschlossene Matches nach so vielen Tagen archivieren; `0` = nie |
|
||||||
|
|||||||
@@ -6,12 +6,6 @@
|
|||||||
<meta name="theme-color" content="#000000" />
|
<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="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 />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/barlow": "5.3.0",
|
||||||
|
"@fontsource/barlow-condensed": "5.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import CreateMatch from './pages/CreateMatch.jsx';
|
|||||||
import MatchPage from './pages/MatchPage.jsx';
|
import MatchPage from './pages/MatchPage.jsx';
|
||||||
import StreamPage from './pages/StreamPage.jsx';
|
import StreamPage from './pages/StreamPage.jsx';
|
||||||
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
|
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
|
||||||
|
import BeamerPage from './pages/BeamerPage.jsx';
|
||||||
import AdminPage from './pages/AdminPage.jsx';
|
import AdminPage from './pages/AdminPage.jsx';
|
||||||
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ export default function App() {
|
|||||||
<Route path="/stream/:code" element={<StreamPage />} />
|
<Route path="/stream/:code" element={<StreamPage />} />
|
||||||
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
|
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
|
||||||
<Route path="/stream" element={<StreamOverviewPage />} />
|
<Route path="/stream" element={<StreamOverviewPage />} />
|
||||||
|
{/* Diashow der laufenden Matches für den Beamer, ein Match pro Folie */}
|
||||||
|
<Route path="/beamer" element={<BeamerPage />} />
|
||||||
<Route path="*" element={<MainLayout />} />
|
<Route path="*" element={<MainLayout />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -44,6 +47,7 @@ function MainLayout() {
|
|||||||
<nav>
|
<nav>
|
||||||
<Link to="/" className="nav-link">Übersicht</Link>
|
<Link to="/" className="nav-link">Übersicht</Link>
|
||||||
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
|
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
|
||||||
|
<a href="/beamer" target="_blank" rel="noreferrer" className="nav-link">Beamer</a>
|
||||||
<Link to="/admin" className="nav-link">Admin</Link>
|
<Link to="/admin" className="nav-link">Admin</Link>
|
||||||
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/* ---------- Beamer-Diashow (1920×1080, skaliert): ein Match pro Folie, groß und aus der Ferne lesbar ---------- */
|
||||||
|
.bm-root {
|
||||||
|
position: relative; width: 100%; height: 100%; overflow: hidden; cursor: pointer;
|
||||||
|
display: grid; grid-template-rows: 104px 1fr 56px; padding: 0 72px;
|
||||||
|
color: var(--text); font-family: var(--body);
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 600px at 0% 40%, rgba(52, 74, 153, .3), transparent 70%),
|
||||||
|
radial-gradient(900px 600px at 100% 40%, rgba(167, 42, 28, .28), transparent 70%),
|
||||||
|
#000;
|
||||||
|
}
|
||||||
|
.bm-root > :not(.bm-art) { position: relative; z-index: 1; }
|
||||||
|
.bm-art { position: absolute; inset: 0; opacity: .32; }
|
||||||
|
.bm-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .92) 70%); }
|
||||||
|
|
||||||
|
.bm-main { display: grid; min-height: 0; animation: bm-in .6s ease both; }
|
||||||
|
@keyframes bm-in { from { opacity: 0; transform: translateY(18px); } }
|
||||||
|
|
||||||
|
.bm-slide { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px; min-height: 0; padding: 20px 0; }
|
||||||
|
.bm-meta { display: flex; align-items: center; gap: 22px; font: 600 32px var(--display); color: var(--muted); }
|
||||||
|
.bm-meta .so-badge { font-size: 30px; padding: 6px 18px; }
|
||||||
|
|
||||||
|
.bm-hero { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 56px; width: 100%; }
|
||||||
|
.bm-team { margin: 0; font: 700 128px/1 var(--display); letter-spacing: -.01em; overflow-wrap: anywhere; }
|
||||||
|
.bm-team.a { color: var(--team-a); text-align: right; }
|
||||||
|
.bm-team.b { color: var(--team-b); }
|
||||||
|
.bm-series { display: flex; flex-direction: column; align-items: center; font: 700 130px/1 var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.bm-series span { display: flex; align-items: center; }
|
||||||
|
.bm-series i { font-style: normal; color: var(--muted); padding: 0 14px; }
|
||||||
|
.bm-series small { font: 600 26px var(--display); color: var(--muted); text-transform: uppercase; letter-spacing: .12em; }
|
||||||
|
.bm-vs { font-size: 90px; }
|
||||||
|
|
||||||
|
.bm-map { display: flex; align-items: baseline; gap: 26px; }
|
||||||
|
.bm-map strong { font: 700 64px var(--display); }
|
||||||
|
.bm-map span { font: 600 38px var(--display); color: var(--muted); }
|
||||||
|
|
||||||
|
.bm-score { display: flex; align-items: center; gap: 34px; font: 700 200px/1 var(--display); font-variant-numeric: tabular-nums; }
|
||||||
|
.bm-score i { font-style: normal; color: var(--muted); }
|
||||||
|
.bm-score b.a { color: var(--team-a); }
|
||||||
|
.bm-score b.b { color: var(--team-b); }
|
||||||
|
.bm-score b.won { text-shadow: 0 0 40px currentColor; }
|
||||||
|
|
||||||
|
.bm-maps { list-style: none; margin: 0; padding: 0; display: flex; gap: 18px; justify-content: center; }
|
||||||
|
.bm-maps li {
|
||||||
|
position: relative; overflow: hidden; width: 250px; height: 104px; border-radius: 8px; padding: 12px 16px;
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; border: 2px solid var(--line-strong);
|
||||||
|
}
|
||||||
|
.bm-maps li .map-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .2), rgba(0, 0, 0, .85)); }
|
||||||
|
.bm-maps li > span:not(.map-art), .bm-maps li b { position: relative; z-index: 1; font: 700 28px var(--display); text-shadow: 0 2px 8px #000; }
|
||||||
|
.bm-maps li.is-pending { filter: grayscale(.8) brightness(.6); }
|
||||||
|
.bm-maps li.is-current { border-color: var(--brand-red); box-shadow: 0 0 0 3px var(--brand-red); }
|
||||||
|
|
||||||
|
.bm-note { font: 600 44px var(--display); color: var(--muted); margin: 0; }
|
||||||
|
.bm-turn { display: flex; flex-direction: column; align-items: center; gap: 6px; margin: 0; font: 600 52px var(--display); }
|
||||||
|
.bm-turn .a { color: var(--team-a); }
|
||||||
|
.bm-turn .b { color: var(--team-b); }
|
||||||
|
.bm-turn small { font-size: 28px; color: var(--muted); }
|
||||||
|
.bm-vmaps { width: 100%; height: 330px; }
|
||||||
|
.bm-vmaps .so-vmap-name { font-size: 34px; }
|
||||||
|
|
||||||
|
.bm-summary { display: flex; flex-direction: column; justify-content: center; gap: 40px; min-height: 0; }
|
||||||
|
.bm-idle { margin: 0; text-align: center; font: 700 80px var(--display); }
|
||||||
|
.bm-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 72px; }
|
||||||
|
.bm-columns h3 { margin: 0 0 18px; font: 700 40px var(--display); color: var(--muted); text-transform: uppercase; letter-spacing: .1em; border-bottom: 3px solid var(--brand-red); padding-bottom: 10px; }
|
||||||
|
.bm-columns ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.bm-columns li { display: flex; align-items: baseline; gap: 18px; font: 700 44px var(--display); }
|
||||||
|
.bm-columns li .a { color: var(--team-a); }
|
||||||
|
.bm-columns li .b { color: var(--team-b); }
|
||||||
|
.bm-columns li i { font-style: normal; color: var(--muted); font-size: 32px; }
|
||||||
|
.bm-columns li b { font-variant-numeric: tabular-nums; }
|
||||||
|
.bm-columns li .won { text-decoration: underline; text-underline-offset: 8px; }
|
||||||
|
.bm-columns li small { margin-left: auto; font: 600 28px var(--display); color: var(--muted); }
|
||||||
|
.bm-columns .so-none { font-size: 32px; }
|
||||||
|
|
||||||
|
.bm-bottom { position: relative; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.bm-dots { display: flex; gap: 12px; }
|
||||||
|
.bm-dots span { width: 14px; height: 14px; border-radius: 50%; background: var(--line-strong); }
|
||||||
|
.bm-dots span.on { background: var(--brand-red); }
|
||||||
|
.bm-progress { position: absolute; left: 0; bottom: 0; height: 6px; width: 100%; background: var(--brand-red); transform-origin: left; animation: bm-progress linear both; }
|
||||||
|
@keyframes bm-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const MAX_LINES = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-Konsole eines Containers im Admin-Bereich. Liest `GET /api/admin/logs/...` als Stream
|
||||||
|
* (fetch statt EventSource, weil die Anmeldung im Authorization-Kopf mitgeht).
|
||||||
|
*/
|
||||||
|
export default function ConsoleDialog({ title, path, token, onClose, note }) {
|
||||||
|
const [lines, setLines] = useState([]);
|
||||||
|
const [status, setStatus] = useState('verbinde');
|
||||||
|
const [follow, setFollow] = useState(true);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [run, setRun] = useState(0);
|
||||||
|
const boxRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
let rest = '';
|
||||||
|
setLines([]);
|
||||||
|
setStatus('verbinde');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api${path}?tail=300`, { headers: { authorization: `Bearer ${token}` }, signal: ctl.signal });
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `Fehler ${res.status}`);
|
||||||
|
}
|
||||||
|
setStatus('live');
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const parts = (rest + decoder.decode(value, { stream: true })).split('\n');
|
||||||
|
rest = parts.pop();
|
||||||
|
if (parts.length) setLines((cur) => [...cur, ...parts].slice(-MAX_LINES));
|
||||||
|
}
|
||||||
|
if (rest) setLines((cur) => [...cur, rest].slice(-MAX_LINES));
|
||||||
|
setStatus('beendet');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') setStatus(`Fehler: ${err.message}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => ctl.abort();
|
||||||
|
}, [path, token, run]);
|
||||||
|
|
||||||
|
// Mitlaufen: ans Ende scrollen, solange "Mitlaufen" an ist
|
||||||
|
useEffect(() => {
|
||||||
|
if (follow && boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
|
||||||
|
}, [lines, follow]);
|
||||||
|
|
||||||
|
const onKey = useCallback((e) => { if (e.key === 'Escape') onClose(); }, [onClose]);
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [onKey]);
|
||||||
|
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
const shown = q ? lines.filter((l) => l.toLowerCase().includes(q)) : lines;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="console-backdrop" onClick={onClose}>
|
||||||
|
<section className="console-dialog" role="dialog" aria-modal="true" aria-label={`Konsole ${title}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<header className="console-head">
|
||||||
|
<strong>Konsole: {title}</strong>
|
||||||
|
<span className={`console-status ${status === 'live' ? 'is-live' : ''}`}>{status === 'live' ? 'live' : status}</span>
|
||||||
|
<button className="btn btn-sm" onClick={onClose}>Schließen</button>
|
||||||
|
</header>
|
||||||
|
<div className="console-tools">
|
||||||
|
<input type="search" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Zeilen filtern" aria-label="Zeilen filtern" />
|
||||||
|
<label className="admin-check"><input type="checkbox" checked={follow} onChange={(e) => setFollow(e.target.checked)} /> Mitlaufen</label>
|
||||||
|
<button className="btn btn-sm" onClick={() => setLines([])}>Leeren</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => setRun((n) => n + 1)}>Neu verbinden</button>
|
||||||
|
<span className="small muted">{shown.length} Zeilen</span>
|
||||||
|
</div>
|
||||||
|
<pre className="console-out" ref={boxRef} onScroll={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||||
|
if (atEnd !== follow) setFollow(atEnd);
|
||||||
|
}}>
|
||||||
|
{shown.length ? shown.join('\n') : status === 'verbinde' ? 'Verbinde …' : 'Noch keine Ausgabe.'}
|
||||||
|
</pre>
|
||||||
|
<p className="small muted console-note">{note ?? 'Passwörter sind ausgeblendet.'} Die Ansicht zeigt die letzten {MAX_LINES} Zeilen.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +1,52 @@
|
|||||||
import CopyField from './CopyField.jsx';
|
import CopyField from './CopyField.jsx';
|
||||||
|
|
||||||
|
function regenerate(act, target, label) {
|
||||||
|
if (!window.confirm(`Neuen Code für ${label} erzeugen? Der alte Link funktioniert dann nicht mehr. Bereits beigetretene Spieler bleiben im Team.`)) return;
|
||||||
|
act('codes/regenerate', { target }, `Neuer Code für ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einladungslink eines Teams, steht für den Organisator unten in der Team-Karte. */
|
||||||
|
export function TeamInvite({ state, side, act, busy }) {
|
||||||
|
const { codes, links } = state.organizer;
|
||||||
|
const open = !['finished', 'cancelled'].includes(state.status);
|
||||||
|
const name = state.teams[side].name;
|
||||||
|
return (
|
||||||
|
<div className={`invite team-invite team-${side}`}>
|
||||||
|
<div className="invite-head">
|
||||||
|
<strong>Einladung</strong>
|
||||||
|
<span className="invite-code">{codes[side]}</span>
|
||||||
|
{open && <button className="link-btn" disabled={!!busy} onClick={() => regenerate(act, side, name)}>Neuer Code</button>}
|
||||||
|
</div>
|
||||||
|
<CopyField value={links[side]} />
|
||||||
|
<p className="small muted">Führt nur in {name}. Nur an dieses Team weitergeben.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function OrganizerPanel({ state, act, busy, matchId, token }) {
|
export default function OrganizerPanel({ state, act, busy, matchId, token }) {
|
||||||
const { codes, links } = state.organizer;
|
const { codes, links } = state.organizer;
|
||||||
const open = !['finished', 'cancelled'].includes(state.status);
|
const open = !['finished', 'cancelled'].includes(state.status);
|
||||||
const orgLink = `${window.location.origin}/m/${matchId}#org=${token}`;
|
const orgLink = `${window.location.origin}/m/${matchId}#org=${token}`;
|
||||||
|
const label = 'Zuschauer (Stream, Kommentatoren)';
|
||||||
const regen = (target, label) =>
|
|
||||||
window.confirm(`Neuen Code für ${label} erzeugen? Der alte Link funktioniert dann nicht mehr. Bereits beigetretene Spieler bleiben im Team.`) &&
|
|
||||||
act('codes/regenerate', { target }, `Neuer Code für ${label}`);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel organizer-panel">
|
<section className="panel organizer-panel">
|
||||||
<h2>Einladungen</h2>
|
<h2>Einladungen</h2>
|
||||||
<p className="small muted">Jeder Link führt nur in das jeweilige Team. Gib ihn ausschließlich an dieses Team weiter.</p>
|
<p className="small muted">Die Einladungslinks der Teams stehen unten in der jeweiligen Team-Karte.</p>
|
||||||
{[
|
<div className="invite spectator">
|
||||||
['A', state.teams.A.name, 'team-A'],
|
|
||||||
['B', state.teams.B.name, 'team-B'],
|
|
||||||
['spectator', 'Zuschauer (Stream, Kommentatoren)', 'spectator'],
|
|
||||||
].map(([key, label, cls]) => (
|
|
||||||
<div key={key} className={`invite ${cls}`}>
|
|
||||||
<div className="invite-head">
|
<div className="invite-head">
|
||||||
<strong>{label}</strong>
|
<strong>{label}</strong>
|
||||||
<span className="invite-code">{codes[key]}</span>
|
<span className="invite-code">{codes.spectator}</span>
|
||||||
{open && <button className="link-btn" disabled={!!busy} onClick={() => regen(key, label)}>Neuer Code</button>}
|
{open && <button className="link-btn" disabled={!!busy} onClick={() => regenerate(act, 'spectator', label)}>Neuer Code</button>}
|
||||||
</div>
|
</div>
|
||||||
<CopyField value={links[key]} />
|
<CopyField value={links.spectator} />
|
||||||
{key === 'spectator' && (
|
|
||||||
<div className="stream-link">
|
<div className="stream-link">
|
||||||
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
|
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
|
||||||
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
|
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
|
||||||
<CopyField label="Match-Übersicht (nur dieses Match, ohne Zuschauercode)" value={links.overview} />
|
<CopyField label="Match-Übersicht (nur dieses Match, ohne Zuschauercode)" value={links.overview} />
|
||||||
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
|
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
<details className="org-link">
|
<details className="org-link">
|
||||||
<summary>Organisator-Zugang auf einem anderen Gerät öffnen</summary>
|
<summary>Organisator-Zugang auf einem anderen Gerät öffnen</summary>
|
||||||
<p className="small muted">Dieser Link gibt volle Kontrolle über das Match. Nicht weitergeben.</p>
|
<p className="small muted">Dieser Link gibt volle Kontrolle über das Match. Nicht weitergeben.</p>
|
||||||
|
|||||||
@@ -61,13 +61,14 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!server && <p className="muted small">Für dieses Match wurde noch kein Server angefordert.</p>}
|
{!server && <p className="muted small">Für dieses Match wurde noch kein Server angefordert.</p>}
|
||||||
{server && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && (
|
{server?.knife && <p className="muted small">Vor dem Veto läuft hier die Messerrunde, die Verbindungsdaten stehen in der Lobby. Nach dem Veto wird derselbe Server für das Match eingerichtet.</p>}
|
||||||
|
{server && !server.knife && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && (
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
{sim ? 'Der Start wird während des Vetos simuliert.' : 'Der Server fährt während des Vetos hoch.'} Die Verbindungsdaten erscheinen nach dem Veto.
|
{sim ? 'Der Start wird während des Vetos simuliert.' : 'Der Server fährt während des Vetos hoch.'} Die Verbindungsdaten erscheinen nach dem Veto.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{server?.connect && (
|
{server?.connect && !server.knife && (
|
||||||
<div className="connect">
|
<div className="connect">
|
||||||
<h3>{sim ? 'Spieler-Verbindung (Demo-Beispiel, nicht verbindbar)' : 'Verbindung für Spieler'}</h3>
|
<h3>{sim ? 'Spieler-Verbindung (Demo-Beispiel, nicht verbindbar)' : 'Verbindung für Spieler'}</h3>
|
||||||
<CopyField label="In die CS2-Konsole einfügen" value={server.connect.command} secret />
|
<CopyField label="In die CS2-Konsole einfügen" value={server.connect.command} secret />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TeamInvite } from './OrganizerPanel.jsx';
|
||||||
export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }) {
|
export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }) {
|
||||||
const isOwnTeam = viewer.team === side;
|
const isOwnTeam = viewer.team === side;
|
||||||
const inLobby = state.status === 'lobby';
|
const inLobby = state.status === 'lobby';
|
||||||
@@ -59,6 +60,7 @@ export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{inLobby && !readiness.enough && <p className="small muted">Es fehlen noch {team.teamSize - readiness.count} Spieler.</p>}
|
{inLobby && !readiness.enough && <p className="small muted">Es fehlen noch {team.teamSize - readiness.count} Spieler.</p>}
|
||||||
|
{isOrg && state.organizer && <TeamInvite state={state} side={side} act={act} busy={busy} />}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,13 @@ import React from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
|
// Schriften aus dem eigenen Build statt von Google Fonts: keine Verbindung zu Google, funktioniert auch ohne Internet
|
||||||
|
import '@fontsource/barlow/latin-400.css';
|
||||||
|
import '@fontsource/barlow/latin-500.css';
|
||||||
|
import '@fontsource/barlow/latin-600.css';
|
||||||
|
import '@fontsource/barlow-condensed/latin-500.css';
|
||||||
|
import '@fontsource/barlow-condensed/latin-600.css';
|
||||||
|
import '@fontsource/barlow-condensed/latin-700.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api } from '../lib/api.js';
|
|||||||
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
||||||
import CopyField from '../components/CopyField.jsx';
|
import CopyField from '../components/CopyField.jsx';
|
||||||
import MapImage from '../components/MapImage.jsx';
|
import MapImage from '../components/MapImage.jsx';
|
||||||
|
import ConsoleDialog from '../components/ConsoleDialog.jsx';
|
||||||
|
|
||||||
const KEY = 'cs2mv:admin';
|
const KEY = 'cs2mv:admin';
|
||||||
const FILTER_KEY = 'cs2mv:admin:filter';
|
const FILTER_KEY = 'cs2mv:admin:filter';
|
||||||
@@ -327,6 +328,7 @@ function RollbackPanel({ matchId, token, reload, setNotice }) {
|
|||||||
|
|
||||||
function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [showConsole, setShowConsole] = useState(false);
|
||||||
const server = serverLabel(m.server);
|
const server = serverLabel(m.server);
|
||||||
const closed = ['finished', 'cancelled'].includes(m.status);
|
const closed = ['finished', 'cancelled'].includes(m.status);
|
||||||
const id = m.id;
|
const id = m.id;
|
||||||
@@ -384,7 +386,10 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
|
|
||||||
<div className="admin-actions">
|
<div className="admin-actions">
|
||||||
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
|
<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' })}
|
{(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('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
|
||||||
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
|
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
|
||||||
@@ -392,6 +397,9 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
||||||
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
||||||
{m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })}
|
{m.server && !['stopped'].includes(m.server.status) && btn('Server stoppen', { path: '/server/stop', confirm: 'Server stoppen?' })}
|
||||||
|
{m.server && m.server.status !== 'stopped' && (
|
||||||
|
<button className="btn btn-sm" onClick={() => setShowConsole(true)}>Konsole</button>
|
||||||
|
)}
|
||||||
{!closed && btn('Match abschließen', { path: '/finish', confirm: 'Match als abgeschlossen markieren?' })}
|
{!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 && btn('Match abbrechen', { path: '/cancel', confirm: 'Match abbrechen?' }, 'btn-ghost danger')}
|
||||||
{closed && !m.archivedAt && btn('Archivieren', { path: '/archive', success: 'Match archiviert' })}
|
{closed && !m.archivedAt && btn('Archivieren', { path: '/archive', success: 'Match archiviert' })}
|
||||||
@@ -399,6 +407,10 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
{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')}
|
{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>
|
</div>
|
||||||
|
|
||||||
|
{showConsole && (
|
||||||
|
<ConsoleDialog title={`${m.teams.A.name} vs. ${m.teams.B.name}`} path={`/admin/logs/match/${id}`} token={token} onClose={() => setShowConsole(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="admin-details">
|
<div className="admin-details">
|
||||||
<div className="admin-grid">
|
<div className="admin-grid">
|
||||||
@@ -485,14 +497,19 @@ function HostUsageLive({ token }) {
|
|||||||
clearInterval(t);
|
clearInterval(t);
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
return <HostUsage hosts={hosts} />;
|
return <HostUsage hosts={hosts} token={token} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auslastung der Docker-Hosts: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */
|
/** Auslastung der Docker-Hosts: wie viele Gameserver läuft auf welchem Host, mit Liste der Matches. */
|
||||||
function HostUsage({ hosts }) {
|
function HostUsage({ hosts, token }) {
|
||||||
|
const [logFor, setLogFor] = useState(null);
|
||||||
if (hosts.length === 0) return null;
|
if (hosts.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section className="host-usage">
|
<section className="host-usage">
|
||||||
|
{logFor && (
|
||||||
|
<ConsoleDialog title={`Docker-Host ${logFor}`} path={`/admin/host-log/${encodeURIComponent(logFor)}`} token={token}
|
||||||
|
note="Nur lesen: Ereignisse des Hosts der letzten 2 Stunden und alles Neue." onClose={() => setLogFor(null)} />
|
||||||
|
)}
|
||||||
<h2>Docker-Hosts</h2>
|
<h2>Docker-Hosts</h2>
|
||||||
<div className="host-grid">
|
<div className="host-grid">
|
||||||
{hosts.map((h) => {
|
{hosts.map((h) => {
|
||||||
@@ -503,6 +520,7 @@ function HostUsage({ hosts }) {
|
|||||||
<strong>{h.name}</strong>
|
<strong>{h.name}</strong>
|
||||||
<span className="muted small">{h.publicHost}</span>
|
<span className="muted small">{h.publicHost}</span>
|
||||||
<span className="host-count">{h.used} / {h.total} belegt</span>
|
<span className="host-count">{h.used} / {h.total} belegt</span>
|
||||||
|
{token && <button className="btn btn-sm host-log-btn" onClick={() => setLogFor(h.name)}>Live-Log</button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="host-bar" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
<div className="host-bar" role="progressbar" aria-valuenow={percent} aria-valuemin={0} aria-valuemax={100}>
|
||||||
<span style={{ width: `${percent}%` }} />
|
<span style={{ width: `${percent}%` }} />
|
||||||
@@ -667,13 +685,15 @@ function DockerHostsPanel({ token, setNotice }) {
|
|||||||
|
|
||||||
// Während das Image lädt, alle 3 Sekunden den Stand holen (ohne die bearbeiteten Zeilen zu überschreiben)
|
// Während das Image lädt, alle 3 Sekunden den Stand holen (ohne die bearbeiteten Zeilen zu überschreiben)
|
||||||
const pulling = !!data?.pull?.running;
|
const pulling = !!data?.pull?.running;
|
||||||
|
const updating = !!data?.files?.running;
|
||||||
|
const [forceFiles, setForceFiles] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pulling) return undefined;
|
if (!pulling && !updating) return undefined;
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
load().then((res) => setData((d) => ({ ...d, pull: res.pull }))).catch(() => {});
|
load().then((res) => setData((d) => ({ ...d, pull: res.pull, files: res.files }))).catch(() => {});
|
||||||
}, 3000);
|
}, 3000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [pulling, load]);
|
}, [pulling, updating, load]);
|
||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
if (!data.editable) {
|
if (!data.editable) {
|
||||||
@@ -724,6 +744,14 @@ function DockerHostsPanel({ token, setNotice }) {
|
|||||||
run(() => api('/admin/docker-hosts', { method: 'DELETE', token }), 'Hosts aus der .env aktiv');
|
run(() => api('/admin/docker-hosts', { method: 'DELETE', token }), 'Hosts aus der .env aktiv');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateFiles = () => {
|
||||||
|
const warn = forceFiles
|
||||||
|
? 'Spieldateien auf ALLEN Hosts aktualisieren, auch wo Server laufen? Laufende Server können dabei abstürzen.'
|
||||||
|
: 'Spieldateien auf allen Hosts ohne laufende Server aktualisieren?';
|
||||||
|
if (!window.confirm(warn)) return;
|
||||||
|
run(() => api('/admin/docker-hosts/update-files', { method: 'POST', body: { force: forceFiles }, token }));
|
||||||
|
};
|
||||||
|
|
||||||
const pull = () => {
|
const pull = () => {
|
||||||
if (!window.confirm(`Image ${data.image} auf allen Hosts neu laden? Laufende Server laufen weiter.`)) return;
|
if (!window.confirm(`Image ${data.image} auf allen Hosts neu laden? Laufende Server laufen weiter.`)) return;
|
||||||
run(() => api('/admin/docker-hosts/pull', { method: 'POST', token }));
|
run(() => api('/admin/docker-hosts/pull', { method: 'POST', token }));
|
||||||
@@ -781,9 +809,51 @@ function DockerHostsPanel({ token, setNotice }) {
|
|||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<h3>Spieldateien aktualisieren</h3>
|
||||||
|
<p className="small muted">
|
||||||
|
Holt das neueste CS2-Update per SteamCMD in das Volume jedes Hosts. Das Image-Update oben reicht dafür nicht.
|
||||||
|
Hosts mit laufenden Servern werden ausgelassen, damit niemandem die Dateien unter dem laufenden Server ausgetauscht werden.
|
||||||
|
Bestehende Server nutzen die neuen Dateien erst nach einem Neustart.
|
||||||
|
</p>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="btn btn-sm btn-primary" disabled={busy || updating || dirty} onClick={updateFiles}>
|
||||||
|
{updating ? filesLabel(data.files) : 'Spieldateien aktualisieren'}
|
||||||
|
</button>
|
||||||
|
<label className="admin-check small">
|
||||||
|
<input type="checkbox" checked={forceFiles} onChange={(e) => setForceFiles(e.target.checked)} disabled={updating} />
|
||||||
|
auch Hosts mit laufenden Servern
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{data.files?.results?.length > 0 && (
|
||||||
|
<ul className="pull-results">
|
||||||
|
{data.files.results.map((r) => (
|
||||||
|
<li key={r.name} className={`pull-${r.state === 'skipped' ? 'skipped' : r.state}`}>
|
||||||
|
<strong>{r.name}</strong>
|
||||||
|
<span>{r.state === 'running' ? 'läuft' : r.state === 'ok' ? 'fertig' : r.state === 'skipped' ? 'ausgelassen' : 'fehlgeschlagen'}</span>
|
||||||
|
{r.message && <code className="small">{r.message}</code>}
|
||||||
|
{r.state === 'running' && r.progress != null && (
|
||||||
|
<span className="files-bar" role="progressbar" aria-valuenow={Math.round(r.progress)} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<i style={{ width: `${Math.min(100, r.progress)}%` }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{data.files.finishedAt && (
|
||||||
|
<li className="small muted">Zuletzt fertig: {new Date(data.files.finishedAt).toLocaleString('de-DE')}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/** Knopftext während des Spieldateien-Updates: "Update läuft 43 %" (niedrigster Stand der laufenden Hosts) */
|
||||||
|
function filesLabel(files) {
|
||||||
|
const running = files?.results?.filter((r) => r.state === 'running') ?? [];
|
||||||
|
const known = running.filter((r) => r.progress != null).map((r) => r.progress);
|
||||||
|
return known.length ? `Update läuft ${Math.floor(Math.min(...known))} %` : 'Update läuft …';
|
||||||
|
}
|
||||||
|
|
||||||
/** Datum und Uhrzeit auf Deutsch, z. B. 29.10.2026, 12:54 Uhr */
|
/** Datum und Uhrzeit auf Deutsch, z. B. 29.10.2026, 12:54 Uhr */
|
||||||
function formatDateTime(ms) {
|
function formatDateTime(ms) {
|
||||||
return `${new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} Uhr`;
|
return `${new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} Uhr`;
|
||||||
@@ -828,6 +898,7 @@ const KNIFE_STATUS = {
|
|||||||
/** Messer-Server für die Seitenwahl: laufen dauerhaft, setzen sich nach jeder Messerrunde selbst zurück. */
|
/** Messer-Server für die Seitenwahl: laufen dauerhaft, setzen sich nach jeder Messerrunde selbst zurück. */
|
||||||
function KnifePanel({ token, setNotice }) {
|
function KnifePanel({ token, setNotice }) {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
|
const [consoleFor, setConsoleFor] = useState(null);
|
||||||
const [mode, setMode] = useState('');
|
const [mode, setMode] = useState('');
|
||||||
const [map, setMap] = useState('');
|
const [map, setMap] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -901,6 +972,7 @@ function KnifePanel({ token, setNotice }) {
|
|||||||
<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}/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 || !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" 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" onClick={() => setConsoleFor(k)}>Konsole</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>
|
<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>
|
||||||
@@ -908,6 +980,9 @@ function KnifePanel({ token, setNotice }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{consoleFor && (
|
||||||
|
<ConsoleDialog title={consoleFor.name} path={`/admin/logs/knife/${consoleFor.id}`} token={token} onClose={() => setConsoleFor(null)} />
|
||||||
|
)}
|
||||||
<div className="admin-actions knife-new">
|
<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}>
|
<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>)}
|
{data.modes.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||||
|
import MapImage from '../components/MapImage.jsx';
|
||||||
|
import SideIcon from '../components/SideIcon.jsx';
|
||||||
|
import { ScaledStage, useClock } from './StreamPage.jsx';
|
||||||
|
import { useStreamOverview } from './StreamOverviewPage.jsx';
|
||||||
|
import '../stream.css';
|
||||||
|
import '../stream-overview.css';
|
||||||
|
import '../beamer.css';
|
||||||
|
|
||||||
|
const DEFAULT_SECONDS = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diashow der laufenden Matches für den Beamer: /beamer
|
||||||
|
* Ein Match pro Folie, schaltet automatisch weiter. Am Ende eine Folie mit Lobbys und Ergebnissen.
|
||||||
|
* Optional ?sekunden=20 (5 bis 120) und ?key=<STREAM_KEY>.
|
||||||
|
* Tasten: ← → blättern, Leertaste anhalten, F Vollbild. Klick schaltet Vollbild um.
|
||||||
|
*/
|
||||||
|
export default function BeamerPage() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const key = params.get('key') ?? '';
|
||||||
|
const seconds = Math.max(5, Math.min(120, Number.parseInt(params.get('sekunden'), 10) || DEFAULT_SECONDS));
|
||||||
|
const { data, error } = useStreamOverview(key);
|
||||||
|
const now = useClock();
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.add('stream-mode');
|
||||||
|
return () => document.documentElement.classList.remove('stream-mode');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const slides = buildSlides(data);
|
||||||
|
const count = slides.length;
|
||||||
|
const current = slides[count ? index % count : 0];
|
||||||
|
// Folie über die ID merken, damit sich bei neuen Daten nicht die angezeigte Folie verschiebt
|
||||||
|
const currentId = current?.id;
|
||||||
|
|
||||||
|
const go = useCallback((step) => setIndex((i) => (count ? (i + step + count) % count : 0)), [count]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || count <= 1) return undefined;
|
||||||
|
const t = setTimeout(() => go(1), seconds * 1000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [index, paused, count, seconds, go, currentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'ArrowRight') go(1);
|
||||||
|
else if (e.key === 'ArrowLeft') go(-1);
|
||||||
|
else if (e.key === ' ') { e.preventDefault(); setPaused((p) => !p); } else if (e.key === 'f' || e.key === 'F') toggleFullscreen();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [go]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ScaledStage>
|
||||||
|
<div className="st-message">
|
||||||
|
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
|
||||||
|
<h1>Diashow nicht verfügbar</h1>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</ScaledStage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = (data?.active ?? []).filter((m) => m.status === 'live').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScaledStage>
|
||||||
|
<div className="bm-root" onClick={toggleFullscreen}>
|
||||||
|
{current?.type === 'match' && <Backdrop m={current.match} />}
|
||||||
|
<header className="st-top bm-top">
|
||||||
|
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
|
||||||
|
<div className="so-title">
|
||||||
|
<strong>Counter-Strike 2</strong>
|
||||||
|
{live > 0 && <span className="so-count so-count-live">{live} live</span>}
|
||||||
|
{count > 1 && <span className="so-count">{(index % count) + 1} / {count}</span>}
|
||||||
|
{paused && <span className="so-count">angehalten</span>}
|
||||||
|
</div>
|
||||||
|
<div className="st-clock"><time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="bm-main" key={currentId ?? 'leer'}>
|
||||||
|
{!data && <div className="so-empty">Lade Matches …</div>}
|
||||||
|
{current?.type === 'match' && <MatchSlide m={current.match} />}
|
||||||
|
{current?.type === 'summary' && <SummarySlide data={data} />}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="bm-bottom">
|
||||||
|
{count > 1 && (
|
||||||
|
<div className="bm-dots">
|
||||||
|
{slides.map((s, i) => <span key={s.id} className={i === index % count ? 'on' : ''} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{count > 1 && !paused && <div className="bm-progress" key={`${currentId}-${index}`} style={{ animationDuration: `${seconds}s` }} />}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</ScaledStage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Match pro Folie, danach eine Übersicht mit Lobbys und Ergebnissen (wenn es welche gibt). */
|
||||||
|
function buildSlides(data) {
|
||||||
|
if (!data) return [];
|
||||||
|
const slides = data.active.map((m) => ({ id: m.id, type: 'match', match: m }));
|
||||||
|
if (data.upcoming.length || data.results.length || slides.length === 0) slides.push({ id: 'summary', type: 'summary' });
|
||||||
|
return slides;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
|
||||||
|
else document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMap(m) {
|
||||||
|
return m.series.find((s) => s.number === m.currentMapNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Backdrop({ m }) {
|
||||||
|
const map = currentMap(m);
|
||||||
|
if (m.status === 'veto' || !map) return null;
|
||||||
|
return <MapImage map={map} className="bm-art" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchSlide({ m }) {
|
||||||
|
const map = currentMap(m);
|
||||||
|
const isBo1 = m.format === 'bo1';
|
||||||
|
const playing = m.status === 'live' && map?.live?.status === 'live' && map.status !== 'done';
|
||||||
|
const badge = m.status === 'veto' ? 'Map-Veto' : m.status === 'server_setup' ? 'Server startet' : playing ? 'Live' : map?.status === 'done' ? 'Map beendet' : 'Aufwärmphase';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`bm-slide status-${m.status}`}>
|
||||||
|
<div className="bm-meta">
|
||||||
|
<span>{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
|
||||||
|
<span className={`so-badge ${playing ? 'badge-live' : ''}`}>{badge}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-hero">
|
||||||
|
<h1 className="bm-team a">{m.teams.A}</h1>
|
||||||
|
<div className="bm-series">
|
||||||
|
{!isBo1 && m.status !== 'veto' ? (
|
||||||
|
<>
|
||||||
|
<span>{m.seriesScore.A}<i>:</i>{m.seriesScore.B}</span>
|
||||||
|
<small>Maps</small>
|
||||||
|
</>
|
||||||
|
) : <i className="bm-vs">vs</i>}
|
||||||
|
</div>
|
||||||
|
<h1 className="bm-team b">{m.teams.B}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.status === 'veto' && <VetoPart m={m} />}
|
||||||
|
{m.status === 'server_setup' && map && <p className="bm-note">Server wird eingerichtet: Map {map.number}, {map.name}</p>}
|
||||||
|
{m.status === 'live' && map && <LivePart m={m} map={map} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LivePart({ m, map }) {
|
||||||
|
const sides = map.live?.sides;
|
||||||
|
const sideOf = (t) => (sides ? (sides.CT === t ? 'CT' : 'T') : null);
|
||||||
|
const finished = map.status === 'done';
|
||||||
|
const rounds = map.live?.roundsPlayed ?? 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bm-map">
|
||||||
|
<strong>{map.name}</strong>
|
||||||
|
<span>{finished ? 'Endstand' : map.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bm-score">
|
||||||
|
{sideOf('A') && <SideIcon side={sideOf('A')} size={72} />}
|
||||||
|
<b className={`a ${map.winner === 'A' ? 'won' : ''}`}>{map.score.A}</b>
|
||||||
|
<i>:</i>
|
||||||
|
<b className={`b ${map.winner === 'B' ? 'won' : ''}`}>{map.score.B}</b>
|
||||||
|
{sideOf('B') && <SideIcon side={sideOf('B')} size={72} />}
|
||||||
|
</div>
|
||||||
|
{m.series.length > 1 && (
|
||||||
|
<ol className="bm-maps">
|
||||||
|
{m.series.map((s) => (
|
||||||
|
<li key={s.number} className={`is-${s.status} ${s.number === map.number ? 'is-current' : ''}`} style={{ '--map': s.color ?? '#556' }}>
|
||||||
|
<MapImage map={s} />
|
||||||
|
<span>{s.name}</span>
|
||||||
|
{(s.status === 'done' || s.number === map.number) && <b>{s.score.A}:{s.score.B}</b>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VetoPart({ m }) {
|
||||||
|
const { veto, teams } = m;
|
||||||
|
const c = veto.current;
|
||||||
|
const action = c && ({ ban: 'bannt eine Map', pick: 'wählt eine Map', side: 'wählt die Seite' })[c.type];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="bm-turn">
|
||||||
|
{c ? <><strong className={c.team === 'A' ? 'a' : 'b'}>{teams[c.team]}</strong> {action}</> : 'Veto abgeschlossen'}
|
||||||
|
<small>Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal}</small>
|
||||||
|
</p>
|
||||||
|
<div className="so-vmaps bm-vmaps" style={{ '--count': veto.maps.length }}>
|
||||||
|
{veto.maps.map((x) => (
|
||||||
|
<div key={x.id} className={`so-vmap status-${x.status} ${x.by ? `by-${x.by}` : ''}`} style={{ '--map': x.color ?? '#556' }}>
|
||||||
|
<MapImage map={x} />
|
||||||
|
<span className="so-vmap-name">{x.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummarySlide({ data }) {
|
||||||
|
const score = (m) => (m.format === 'bo1' && m.series[0] ? m.series[0].score : m.seriesScore);
|
||||||
|
return (
|
||||||
|
<section className="bm-summary">
|
||||||
|
{data.active.length === 0 && <h2 className="bm-idle">Gerade läuft kein Match</h2>}
|
||||||
|
<div className="bm-columns">
|
||||||
|
<div>
|
||||||
|
<h3>Gleich</h3>
|
||||||
|
{data.upcoming.length ? (
|
||||||
|
<ul>
|
||||||
|
{data.upcoming.map((m) => (
|
||||||
|
<li key={m.id}>
|
||||||
|
<span className="a">{m.teams.A}</span><i>vs</i><span className="b">{m.teams.B}</span>
|
||||||
|
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : <p className="so-none">Keine offenen Lobbys</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Ergebnisse</h3>
|
||||||
|
{data.results.length ? (
|
||||||
|
<ul>
|
||||||
|
{data.results.map((m) => {
|
||||||
|
const s = score(m);
|
||||||
|
return (
|
||||||
|
<li key={m.id}>
|
||||||
|
<span className={`a ${s.A > s.B ? 'won' : ''}`}>{m.teams.A}</span>
|
||||||
|
<b>{s.A}:{s.B}</b>
|
||||||
|
<span className={`b ${s.B > s.A ? 'won' : ''}`}>{m.teams.B}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : <p className="so-none">Noch keine Ergebnisse</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ export default function CreateMatch({ config }) {
|
|||||||
<option value="coin">Münzwurf beim Veto-Start</option>
|
<option value="coin">Münzwurf beim Veto-Start</option>
|
||||||
<option value="A">{form.teamA || 'Team A'}</option>
|
<option value="A">{form.teamA || 'Team A'}</option>
|
||||||
<option value="B">{form.teamB || 'Team B'}</option>
|
<option value="B">{form.teamB || 'Team B'}</option>
|
||||||
|
<option value="knife">Messerrunde: Organisator legt danach fest, wer beginnt</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import SeriesList from '../components/SeriesList.jsx';
|
|||||||
import EventLog from '../components/EventLog.jsx';
|
import EventLog from '../components/EventLog.jsx';
|
||||||
import MapImage from '../components/MapImage.jsx';
|
import MapImage from '../components/MapImage.jsx';
|
||||||
import SelfJoin from '../components/SelfJoin.jsx';
|
import SelfJoin from '../components/SelfJoin.jsx';
|
||||||
|
import ChatBox from '../components/ChatBox.jsx';
|
||||||
|
import CopyField from '../components/CopyField.jsx';
|
||||||
|
|
||||||
export default function MatchPage() {
|
export default function MatchPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -137,6 +139,7 @@ export default function MatchPage() {
|
|||||||
|
|
||||||
{viewer.role !== 'public' && (
|
{viewer.role !== 'public' && (
|
||||||
<div className="match-lower">
|
<div className="match-lower">
|
||||||
|
{state.chat && <ChatBox chat={state.chat} matchId={id} token={token} playerToken={playerToken} />}
|
||||||
<ServerPanel {...ctx} />
|
<ServerPanel {...ctx} />
|
||||||
{isOrg && <OrganizerPanel {...ctx} token={token} />}
|
{isOrg && <OrganizerPanel {...ctx} token={token} />}
|
||||||
<EventLog events={state.events} />
|
<EventLog events={state.events} />
|
||||||
@@ -170,7 +173,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>)}
|
{missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{isOrg ? (
|
{state.vetoStart === 'knife' && (state.server?.knife ? <OwnKnifeServer state={state} isOrg={isOrg} act={act} busy={busy} /> : <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}
|
<button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy}
|
||||||
onClick={() => act('veto/start', null, 'Veto gestartet')}>
|
onClick={() => act('veto/start', null, 'Veto gestartet')}>
|
||||||
@@ -178,6 +195,8 @@ function Stage(ctx) {
|
|||||||
</button>
|
</button>
|
||||||
{!state.canStartVeto && <p className="small muted">Der Knopf wird aktiv, sobald beide Teams bereit sind.</p>}
|
{!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">
|
<p className="small muted">
|
||||||
{missing.length === 0
|
{missing.length === 0
|
||||||
@@ -200,6 +219,109 @@ function Stage(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Sagt konkret, was einem Team noch fehlt, damit es als bereit gilt. */
|
/** 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).
|
||||||
|
*/
|
||||||
|
/** Messerrunde auf dem eigenen Match-Server: derselbe Server wird nach dem Veto für das Match eingerichtet. */
|
||||||
|
function OwnKnifeServer({ state, isOrg, act, busy }) {
|
||||||
|
const { server } = state;
|
||||||
|
const { knife, connect } = server;
|
||||||
|
const live = knife.phase === 'live';
|
||||||
|
return (
|
||||||
|
<div className="knife-round">
|
||||||
|
<h3>Seitenwahl per Messerrunde</h3>
|
||||||
|
<p className="small">
|
||||||
|
Für euer Match läuft ein eigener Server. Beide Teams verbinden sich, spielen dort eine Runde nur mit Messer, danach legt der
|
||||||
|
Organisator fest, welches Team das Veto beginnt. Nach dem Veto geht es auf demselben Server weiter.
|
||||||
|
</p>
|
||||||
|
{knife.failed ? (
|
||||||
|
<>
|
||||||
|
<p className="small tone-error">{server.message}</p>
|
||||||
|
{isOrg ? (
|
||||||
|
<button className="btn btn-sm btn-primary" disabled={!!busy} onClick={() => act('server/retry', null, 'Messerrunde wird neu eingerichtet')}>
|
||||||
|
Messerrunde neu einrichten
|
||||||
|
</button>
|
||||||
|
) : <p className="small muted">Der Organisator kann die Messerrunde neu einrichten.</p>}
|
||||||
|
</>
|
||||||
|
) : !knife.ready ? (
|
||||||
|
<p className="small tone-pending">
|
||||||
|
<span className="status-dot tone-pending pulse" /> Der Server fährt hoch, die Verbindungsdaten erscheinen gleich hier.
|
||||||
|
{server.message && <span className="muted"> ({server.message})</span>}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className={`small ${live ? 'tone-ok' : 'tone-ok'}`}>
|
||||||
|
<span className={`status-dot ${live ? 'tone-ok' : 'tone-ok'}`} />
|
||||||
|
{live ? ' Messerrunde läuft' : ' Aufwärmphase: verbinden und warten, bis der Organisator die Runde startet'}
|
||||||
|
{server.playerCount != null && <span className="muted"> · {server.playerCount} auf dem Server</span>}
|
||||||
|
</p>
|
||||||
|
{connect && (
|
||||||
|
<div className="knife-connect">
|
||||||
|
<CopyField label="In die CS2-Konsole einfügen" value={connect.command} secret />
|
||||||
|
<a className="btn btn-primary" href={connect.steamUrl}>In CS2 verbinden</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOrg && (
|
||||||
|
<div className="row knife-actions">
|
||||||
|
{!live && <button className="btn btn-sm btn-primary" disabled={!!busy} onClick={() => act('server/knife-start', null, 'Messerrunde gestartet')}>Messerrunde starten</button>}
|
||||||
|
<button className="btn btn-sm" disabled={!!busy} onClick={() => act('server/knife-reset', null, 'Zurück in der Aufwärmphase')}>
|
||||||
|
{live ? 'Runde wiederholen (Aufwärmphase)' : 'Aufwärmphase neu starten'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KnifeRound({ state }) {
|
||||||
|
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) {
|
function readinessGap(team, requireFull) {
|
||||||
const r = team.readiness;
|
const r = team.readiness;
|
||||||
const gaps = [];
|
const gaps = [];
|
||||||
|
|||||||
+152
-71
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
||||||
@@ -6,53 +6,145 @@ import { getAccess } from '../lib/session.js';
|
|||||||
import { useOverview } from '../lib/useMatch.js';
|
import { useOverview } from '../lib/useMatch.js';
|
||||||
import MapImage from '../components/MapImage.jsx';
|
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() {
|
export default function Overview() {
|
||||||
const { matches } = useOverview();
|
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');
|
||||||
|
// Nur echte Ergebnisse: abgebrochene Matches haben keins und stehen nicht auf der Startseite
|
||||||
|
const closed = (matches ?? []).filter((m) => m.group === 'closed' && m.status === 'finished');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overview">
|
<div className="overview home">
|
||||||
<section className="overview-head">
|
<section className={`home-hero ${mine.length ? 'has-mine' : ''}`}>
|
||||||
<div>
|
<div className="home-intro">
|
||||||
<h1>Match-Lobbys</h1>
|
<h1>Mit deinem Team ins Match</h1>
|
||||||
<p className="lead">Teams treten mit ihrem Einladungscode bei, die Captains führen das Map-Veto, der Server wird automatisch eingerichtet.</p>
|
<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>
|
</div>
|
||||||
<CodeEntry />
|
{mine.length > 0 && <MyMatches matches={mine} />}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<KnifeServers />
|
<Steps current={step} />
|
||||||
|
|
||||||
|
|
||||||
{matches === null ? (
|
{matches === null ? (
|
||||||
<p className="muted">Lade Matches …</p>
|
<p className="muted">Lade Matches …</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="groups">
|
<>
|
||||||
{GROUPS.map((g) => {
|
<section className="group home-live">
|
||||||
const list = matches.filter((m) => m.group === g.key);
|
<h2>Laufend <span className="count">{running.length}</span></h2>
|
||||||
return (
|
{running.length === 0 ? (
|
||||||
<section key={g.key} className={`group group-${g.key}`}>
|
<p className="muted small">Gerade läuft kein Match.</p>
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="match-list">
|
<div className="run-grid">
|
||||||
{list.map((m) => <MatchRow key={m.id} match={m} />)}
|
{running.map((m) => <RunningCard key={m.id} match={m} />)}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</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 }) {
|
function MatchRow({ match: m }) {
|
||||||
const access = getAccess(m.id);
|
const access = getAccess(m.id);
|
||||||
const server = serverLabel(m.server);
|
const server = serverLabel(m.server);
|
||||||
@@ -114,61 +206,50 @@ 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 [code, setCode] = useState('');
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const complete = code.replace(/-/g, '').length === 8;
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await api('/access', { method: 'POST', body: { code } });
|
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) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<form className="code-entry" onSubmit={submit}>
|
<form className="ticket" onSubmit={submit}>
|
||||||
<label htmlFor="code">Einladungs- oder Zuschauercode</label>
|
<label className="ticket-stub" htmlFor="code">Code</label>
|
||||||
<div className="row">
|
<input
|
||||||
<input id="code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="ABCD-EFGH" autoComplete="off" spellCheck="false" />
|
id="code"
|
||||||
<button className="btn" disabled={code.replace(/[^a-z0-9]/gi, '').length < 8}>Beitreten</button>
|
className="ticket-code"
|
||||||
</div>
|
value={code}
|
||||||
{error && <p className="field-error">{error}</p>}
|
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>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -141,7 +141,7 @@ function matchHref(id, key) {
|
|||||||
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
|
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useStreamOverview(key) {
|
export function useStreamOverview(key) {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -645,3 +645,137 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
.settings-nav-item small { display: none; }
|
.settings-nav-item small { display: none; }
|
||||||
.settings-nav-item.is-active { border-color: var(--primary); }
|
.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; }
|
||||||
|
/* Live-Konsole der Container */
|
||||||
|
.console-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 16px; background: rgba(var(--bg-rgb), .78); }
|
||||||
|
.console-dialog { display: flex; flex-direction: column; width: min(1100px, 100%); height: min(760px, 100%); background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius); overflow: hidden; }
|
||||||
|
.console-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.console-head strong { font: 700 1.1rem var(--display); margin-right: auto; }
|
||||||
|
.console-status { font-size: .8rem; color: var(--muted); }
|
||||||
|
.console-status.is-live { color: var(--ok); }
|
||||||
|
.console-status.is-live::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: var(--ok); vertical-align: middle; }
|
||||||
|
.console-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 8px 16px; border-bottom: 1px solid var(--line); }
|
||||||
|
.console-tools input[type="search"] { flex: 1 1 200px; min-width: 0; }
|
||||||
|
.console-out { flex: 1; margin: 0; padding: 12px 16px; overflow: auto; background: #05060a; color: #cfd6e6; font: .8rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.console-note { margin: 0; padding: 6px 16px 10px; }
|
||||||
|
.host-log-btn { margin-left: 4px; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.console-backdrop { padding: 0; }
|
||||||
|
.console-dialog { height: 100%; border-radius: 0; }
|
||||||
|
}
|
||||||
|
.pull-skipped span { color: var(--muted); }
|
||||||
|
.files-bar { flex: 1 1 160px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
|
||||||
|
.files-bar i { display: block; height: 100%; background: var(--primary); transition: width .4s ease; }
|
||||||
|
.knife-connect { display: flex; flex-wrap: wrap; align-items: end; gap: 8px 12px; margin-top: 10px; }
|
||||||
|
.knife-connect > :first-child { flex: 1 1 260px; }
|
||||||
|
.knife-actions { margin-top: 10px; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.team-invite { margin-top: 12px; padding-right: 0; }
|
||||||
|
.team-invite .copy-row { flex-wrap: wrap; }
|
||||||
|
.team-invite .copy-row code { flex: 1 1 100%; font-size: .78rem; white-space: normal; word-break: break-all; overflow: visible; }
|
||||||
|
.team-invite p { margin: 6px 0 0; }
|
||||||
Generated
+20
@@ -23,6 +23,8 @@
|
|||||||
"name": "@cs2-mapveto/client",
|
"name": "@cs2-mapveto/client",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/barlow": "5.3.0",
|
||||||
|
"@fontsource/barlow-condensed": "5.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
@@ -725,6 +727,24 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/barlow": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/barlow/-/barlow-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-tGR0y6QObRtKv71MOth44222+mdNFM/ZWFWDV1VLzNbG4oK9PNRlyJzmkck4vHAi9ibHBXgt4SlY51amFP55CA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/barlow-condensed": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/barlow-condensed/-/barlow-condensed-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-RA7MmIx0v3eQ6Gcf74qLVLOwLB0GpPOzx+eU9iG/AmCnSJGcIWYeU8y6CEMGZ210MGwGg5CZhzLkkvyZdTTk1g==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.33.5",
|
"version": "0.33.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||||
|
|||||||
+121
-1
@@ -1,5 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { formatPortList } from './config.js';
|
import { formatPortList } from './config.js';
|
||||||
|
import { formatDockerEvent } from './servers/dockerAdapter.js';
|
||||||
import { HttpError } from './matchService.js';
|
import { HttpError } from './matchService.js';
|
||||||
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
import { createRateLimiter, randomToken, safeEqual } from './security.js';
|
||||||
|
|
||||||
@@ -65,6 +66,8 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
|
|||||||
}
|
}
|
||||||
const token = randomToken();
|
const token = randomToken();
|
||||||
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
|
const expiresAt = Date.now() + SESSION_HOURS * 3_600_000;
|
||||||
|
// Abgelaufene Anmeldungen entfernen, damit die Liste nicht wächst
|
||||||
|
for (const [t, until] of sessions) if (until < Date.now()) sessions.delete(t);
|
||||||
sessions.set(token, expiresAt);
|
sessions.set(token, expiresAt);
|
||||||
return res.json({ token, expiresAt });
|
return res.json({ token, expiresAt });
|
||||||
});
|
});
|
||||||
@@ -103,6 +106,7 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
|
|||||||
editable: !!manager.adapter.setHosts,
|
editable: !!manager.adapter.setHosts,
|
||||||
image: config.docker.image,
|
image: config.docker.image,
|
||||||
pull: manager.adapter.pullStatus?.() ?? null,
|
pull: manager.adapter.pullStatus?.() ?? null,
|
||||||
|
files: manager.adapter.filesStatus?.() ?? null,
|
||||||
});
|
});
|
||||||
router.get('/docker-hosts', requireAdmin, wrap(() => hostView()));
|
router.get('/docker-hosts', requireAdmin, wrap(() => hostView()));
|
||||||
router.put('/docker-hosts', requireAdmin, wrap((req) => {
|
router.put('/docker-hosts', requireAdmin, wrap((req) => {
|
||||||
@@ -118,6 +122,35 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
|
|||||||
return hostView();
|
return hostView();
|
||||||
}));
|
}));
|
||||||
// Image auf allen Hosts neu laden. Läuft im Hintergrund, Stand über GET /docker-hosts.
|
// Image auf allen Hosts neu laden. Läuft im Hintergrund, Stand über GET /docker-hosts.
|
||||||
|
// Spieldateien auf allen Hosts per SteamCMD aktualisieren. Hosts mit laufenden Servern werden ausgelassen,
|
||||||
|
// außer mit { force: true }. Läuft im Hintergrund, Stand über GET /docker-hosts.
|
||||||
|
router.post('/docker-hosts/update-files', requireAdmin, wrap((req) => {
|
||||||
|
if (!manager.adapter.updateGameFiles) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||||||
|
if (manager.adapter.filesStatus().running) throw new HttpError(409, 'Die Spieldateien werden bereits aktualisiert', 'busy');
|
||||||
|
const skip = new Map();
|
||||||
|
if (!req.body?.force) {
|
||||||
|
const byAddress = new Map(manager.adapter.hosts.map((h) => [h.publicHost, h.name]));
|
||||||
|
const busy = new Map();
|
||||||
|
for (const s of manager.activeServers()) {
|
||||||
|
const name = s.hostName ?? byAddress.get(s.host);
|
||||||
|
if (name) busy.set(name, (busy.get(name) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const k of knife.rows()) {
|
||||||
|
if (!k.handle) continue;
|
||||||
|
let name = byAddress.get(k.host);
|
||||||
|
try {
|
||||||
|
name = JSON.parse(k.handle).hostName ?? name;
|
||||||
|
} catch {
|
||||||
|
// kaputter Handle: Adresse reicht
|
||||||
|
}
|
||||||
|
if (name) busy.set(name, (busy.get(name) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const [name, n] of busy) skip.set(name, `ausgelassen: ${n} laufende${n === 1 ? 'r' : ''} Server`);
|
||||||
|
}
|
||||||
|
manager.adapter.updateGameFiles({ skip }).catch(() => {});
|
||||||
|
return hostView();
|
||||||
|
}));
|
||||||
|
|
||||||
router.post('/docker-hosts/pull', requireAdmin, wrap(() => {
|
router.post('/docker-hosts/pull', requireAdmin, wrap(() => {
|
||||||
if (!manager.adapter.pullImage) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
if (!manager.adapter.pullImage) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||||||
if (manager.adapter.pullStatus().running) throw new HttpError(409, 'Das Image wird bereits aktualisiert', 'busy');
|
if (manager.adapter.pullStatus().running) throw new HttpError(409, 'Das Image wird bereits aktualisiert', 'busy');
|
||||||
@@ -164,6 +197,93 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
// Alle Matches mit allen Details (Codes, Verbindungsdaten, Spieler, Veto, Serie, Ereignisse)
|
||||||
|
// Konsolenausgabe eines Containers live (Match-Server oder Messer-Server). Passwörter werden ausgeblendet.
|
||||||
|
let openStreams = 0;
|
||||||
|
router.get('/logs/:kind/:id', requireAdmin, (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { kind, id } = req.params;
|
||||||
|
const row = kind === 'match' ? manager.row(id) : kind === 'knife' ? knife.row(id) : null;
|
||||||
|
if (!row) throw new HttpError(404, 'Server nicht gefunden', 'not_found');
|
||||||
|
if (!row.handle) throw new HttpError(409, 'Für diesen Server gibt es noch keinen Container', 'no_container');
|
||||||
|
if (!manager.adapter.streamLogs) throw new HttpError(501, 'Dieser Adapter hat keine Konsolenausgabe', 'unsupported');
|
||||||
|
if (openStreams >= 8) throw new HttpError(429, 'Zu viele offene Konsolen, erst eine schließen', 'rate_limited');
|
||||||
|
const tail = Math.max(0, Math.min(2000, Number.parseInt(req.query.tail, 10) || 200));
|
||||||
|
const secrets = [row.rcon_password, row.server_password, row.tv_password].filter((s) => s && s.length >= 4);
|
||||||
|
const mask = (text) => secrets.reduce((t, s) => t.split(s).join('••••'), text);
|
||||||
|
|
||||||
|
const proc = manager.adapter.streamLogs(JSON.parse(row.handle), { tail });
|
||||||
|
openStreams += 1;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
|
||||||
|
// Zeilenweise ausgeben, damit ein Passwort nie an einer Paketgrenze zerrissen und so doch sichtbar wird
|
||||||
|
const pipe = (stream) => {
|
||||||
|
let rest = '';
|
||||||
|
stream.setEncoding?.('utf8');
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
const lines = (rest + chunk).split('\n');
|
||||||
|
rest = lines.pop();
|
||||||
|
if (lines.length) res.write(mask(lines.join('\n')) + '\n');
|
||||||
|
});
|
||||||
|
stream.on('end', () => { if (rest) res.write(mask(rest) + '\n'); });
|
||||||
|
};
|
||||||
|
pipe(proc.stdout);
|
||||||
|
pipe(proc.stderr);
|
||||||
|
let done = false;
|
||||||
|
const finish = (note) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
openStreams -= 1;
|
||||||
|
if (note && !res.writableEnded) res.end(`\n[${note}]\n`);
|
||||||
|
};
|
||||||
|
proc.on('close', () => finish('Ausgabe beendet'));
|
||||||
|
proc.on?.('error', (err) => finish(`Fehler: ${err.message}`));
|
||||||
|
req.on('close', () => {
|
||||||
|
proc.kill();
|
||||||
|
finish(null);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live-Log eines Docker-Hosts: seine Ereignisse (Container gestartet, beendet, entfernt, Images geladen …). Nur lesen.
|
||||||
|
router.get('/host-log/:name', requireAdmin, (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!manager.adapter.streamEvents) throw new HttpError(501, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||||||
|
if (openStreams >= 8) throw new HttpError(429, 'Zu viele offene Konsolen, erst eine schließen', 'rate_limited');
|
||||||
|
const since = Math.max(0, Math.min(24 * 60, Number.parseInt(req.query.since, 10) || 120));
|
||||||
|
const proc = manager.adapter.streamEvents(req.params.name, { sinceMinutes: since });
|
||||||
|
if (!proc) throw new HttpError(404, 'Docker-Host nicht gefunden', 'not_found');
|
||||||
|
openStreams += 1;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
|
||||||
|
res.write(`Ereignisse von ${req.params.name} seit ${since} Minuten, neue erscheinen live.\n`);
|
||||||
|
let rest = '';
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
const lines = (rest + chunk).split('\n');
|
||||||
|
rest = lines.pop();
|
||||||
|
const out = lines.map(formatDockerEvent).filter(Boolean);
|
||||||
|
if (out.length) res.write(out.join('\n') + '\n');
|
||||||
|
});
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stderr.on('data', (chunk) => res.write(`[docker] ${chunk}`));
|
||||||
|
let done = false;
|
||||||
|
const finish = (note) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
openStreams -= 1;
|
||||||
|
if (note && !res.writableEnded) res.end(`\n[${note}]\n`);
|
||||||
|
};
|
||||||
|
proc.on('close', () => finish('Ausgabe beendet'));
|
||||||
|
proc.on('error', (err) => finish(`Fehler: ${err.message}`));
|
||||||
|
req.on('close', () => {
|
||||||
|
proc.kill();
|
||||||
|
finish(null);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Auslastung und Speicher der Docker-Hosts mit den Servern darauf
|
// Auslastung und Speicher der Docker-Hosts mit den Servern darauf
|
||||||
router.get('/hosts', requireAdmin, wrap(async () => ({ hosts: await manager.hostUsage() })));
|
router.get('/hosts', requireAdmin, wrap(async () => ({ hosts: await manager.hostUsage() })));
|
||||||
|
|
||||||
@@ -195,7 +315,7 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
|
|||||||
action('server/pause', (v) => manager.setPaused(v, true));
|
action('server/pause', (v) => manager.setPaused(v, true));
|
||||||
action('server/resume', (v) => manager.setPaused(v, false));
|
action('server/resume', (v) => manager.setPaused(v, false));
|
||||||
action('series/next', (v) => manager.loadNextMap(v));
|
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(
|
action('veto/action', (v, b) => service.vetoAction(
|
||||||
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
|
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
|
||||||
{ stepIndex: b.stepIndex, map: b.map, side: b.side },
|
{ stepIndex: b.stepIndex, map: b.map, side: b.side },
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
|||||||
let manager;
|
let manager;
|
||||||
const adapter = adapterOverride ?? createServerAdapter(config, {
|
const adapter = adapterOverride ?? createServerAdapter(config, {
|
||||||
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
|
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
|
||||||
|
getStarting: (host, exclude) => manager.startingOn(host, exclude),
|
||||||
});
|
});
|
||||||
manager = new ServerManager({ db, service, adapter, config, logger });
|
manager = new ServerManager({ db, service, adapter, config, logger });
|
||||||
manager.init();
|
manager.init();
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export function loadConfig(overrides = {}) {
|
|||||||
serverAdapter: str('SERVER_ADAPTER', 'demo'),
|
serverAdapter: str('SERVER_ADAPTER', 'demo'),
|
||||||
idleTimeoutMinutes: int('SERVER_IDLE_TIMEOUT_MINUTES', 30),
|
idleTimeoutMinutes: int('SERVER_IDLE_TIMEOUT_MINUTES', 30),
|
||||||
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
||||||
|
// Offene Lobbys (Veto nie gestartet) werden nach so vielen Stunden gelöscht. 0 = nie
|
||||||
|
lobbyMaxHours: int('LOBBY_MAX_HOURS', 3),
|
||||||
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
|
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
|
||||||
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
||||||
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
|
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
|
||||||
|
|||||||
@@ -118,6 +118,20 @@ CREATE TABLE IF NOT EXISTS knife_servers (
|
|||||||
updated_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 (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
@@ -151,6 +165,9 @@ function migrate(db) {
|
|||||||
}
|
}
|
||||||
const serverColumns = columns('servers');
|
const serverColumns = columns('servers');
|
||||||
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
||||||
|
// Seitenwahl per Messerrunde auf dem Match-Server: wann sie eingerichtet wurde und was gerade läuft
|
||||||
|
if (!serverColumns.has('knife_at')) db.exec('ALTER TABLE servers ADD COLUMN knife_at INTEGER');
|
||||||
|
if (!serverColumns.has('knife_phase')) db.exec('ALTER TABLE servers ADD COLUMN knife_phase TEXT');
|
||||||
// Abgeschlossene Matches lassen sich archivieren; sie verschwinden dann aus der normalen Admin-Liste
|
// Abgeschlossene Matches lassen sich archivieren; sie verschwinden dann aus der normalen Admin-Liste
|
||||||
if (!columns('matches').has('archived_at')) db.exec('ALTER TABLE matches ADD COLUMN archived_at INTEGER');
|
if (!columns('matches').has('archived_at')) db.exec('ALTER TABLE matches ADD COLUMN archived_at INTEGER');
|
||||||
if (!columns('knife_servers').has('mode')) db.exec("ALTER TABLE knife_servers ADD COLUMN mode TEXT NOT NULL DEFAULT 'competitive'");
|
if (!columns('knife_servers').has('mode')) db.exec("ALTER TABLE knife_servers ADD COLUMN mode TEXT NOT NULL DEFAULT 'competitive'");
|
||||||
|
|||||||
+8
-5
@@ -76,10 +76,10 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
|
|||||||
|
|
||||||
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
// Stream-Übersicht aller Matches; mit STREAM_KEY nur mit passendem ?key=
|
||||||
router.get('/stream/overview', wrap((req) => {
|
router.get('/stream/overview', wrap((req) => {
|
||||||
if (config.streamKey && !safeEqual(String(req.query.key ?? ''), config.streamKey)) {
|
// Globaler STREAM_KEY: alle Matches; Match-Schlüssel (vom Organisator): nur dieses Match
|
||||||
throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
|
const access = service.streamAccess(typeof req.query.key === 'string' ? req.query.key : '');
|
||||||
}
|
if (!access) throw new HttpError(403, 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt', 'forbidden');
|
||||||
return service.streamOverview();
|
return service.streamOverview({ matchId: access.matchId });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.post('/matches', limit('create'), wrap((req) => {
|
router.post('/matches', limit('create'), wrap((req) => {
|
||||||
@@ -113,15 +113,18 @@ 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 ?? {})));
|
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('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('leave', (v) => service.leave(v));
|
||||||
action('kick', (v, b) => service.kick(v, b.playerId));
|
action('kick', (v, b) => service.kick(v, b.playerId));
|
||||||
action('captain', (v, b) => service.setCaptain(v, b.playerId));
|
action('captain', (v, b) => service.setCaptain(v, b.playerId));
|
||||||
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
|
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('veto/action', (v, b) => service.vetoAction(v, b));
|
||||||
action('server/retry', (v) => manager.retry(v));
|
action('server/retry', (v) => manager.retry(v));
|
||||||
action('server/stop', (v) => manager.stopByOrganizer(v));
|
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||||
action('server/warmup-end', (v) => manager.endWarmup(v));
|
action('server/warmup-end', (v) => manager.endWarmup(v));
|
||||||
|
action('server/knife-start', (v) => manager.knifeAction(v, 'start'));
|
||||||
|
action('server/knife-reset', (v) => manager.knifeAction(v, 'reset'));
|
||||||
action('series/next', (v) => manager.loadNextMap(v));
|
action('series/next', (v) => manager.loadNextMap(v));
|
||||||
action('finish', (v) => service.close(v, 'finished'));
|
action('finish', (v) => service.close(v, 'finished'));
|
||||||
action('cancel', (v) => service.close(v, 'cancelled'));
|
action('cancel', (v) => service.close(v, 'cancelled'));
|
||||||
|
|||||||
+18
-7
@@ -9,16 +9,26 @@
|
|||||||
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min
|
// Game Over: competitive mg_active de_mirage score 13:9 after 35 min
|
||||||
// Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen)
|
// Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min (Wingman: leere Map-Gruppe, zwei Leerzeichen)
|
||||||
|
|
||||||
|
// Alle Muster gelten für die ganze Zeile nach dem Zeitstempel. Spielerzeilen (z. B. Chat:
|
||||||
|
// "Name<2><[U:1:1]><CT>" say "Game Over: …") beginnen mit einem Anführungszeichen und passen nie.
|
||||||
const RE = {
|
const RE = {
|
||||||
matchStart: /World triggered "Match_Start" on "([^"]+)"/,
|
matchStart: /^World triggered "Match_Start" on "([^"]+)"$/,
|
||||||
warmupStart: /World triggered "Warmup_Start"/,
|
warmupStart: /^World triggered "Warmup_Start"$/,
|
||||||
worldRoundEnd: /World triggered "Round_End"/,
|
worldRoundEnd: /^World triggered "Round_End"$/,
|
||||||
teamPlaying: /Team playing "(CT|TERRORIST)": (.+?)\s*$/,
|
teamPlaying: /^(?:MatchStatus: )?Team playing "(CT|TERRORIST)": (.+?)\s*$/,
|
||||||
roundEnd: /Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)/,
|
roundEnd: /^Team "(CT|TERRORIST)" triggered "SFUI_Notice_(\w+)" \(CT "(\d+)"\) \(T "(\d+)"\)$/,
|
||||||
// Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer.
|
// Das Wort direkt vor "score" ist die Map – Modus und Map-Gruppe davor sind beliebig bzw. leer.
|
||||||
gameOver: /Game Over:.*?(\S+)\s+score\s+(\d+):(\d+)/,
|
gameOver: /^Game Over: [\w ]*?(\S+)\s+score\s+(\d+):(\d+) after \d+ min$/,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Zeitstempel am Zeilenanfang: "10/25/2026 - 18:07:52.396 - " (HTTP-Log) oder "L 10/25/2026 - 18:07:52: " (Datei)
|
||||||
|
const PREFIX = /^(?:L\s+)?\d{2}\/\d{2}\/\d{4}\s+-\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*[-:]\s*/;
|
||||||
|
|
||||||
|
/** Zeile ohne Zeitstempel, so wie sie die Muster oben erwarten. */
|
||||||
|
export function logPayload(line) {
|
||||||
|
return String(line).trim().replace(PREFIX, '');
|
||||||
|
}
|
||||||
|
|
||||||
// Gründe für ein Rundenende aus den SFUI-Hinweisen
|
// Gründe für ein Rundenende aus den SFUI-Hinweisen
|
||||||
const REASONS = {
|
const REASONS = {
|
||||||
Target_Bombed: 'bomb',
|
Target_Bombed: 'bomb',
|
||||||
@@ -85,7 +95,8 @@ export function applyLogLines(state, lines, ctx) {
|
|||||||
Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null });
|
Object.assign(s, { status, score: { A: 0, B: 0 }, rounds: [], winner: null, pendingRound: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const raw of lines) {
|
||||||
|
const line = logPayload(raw);
|
||||||
let m;
|
let m;
|
||||||
if ((m = RE.matchStart.exec(line))) {
|
if ((m = RE.matchStart.exec(line))) {
|
||||||
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
|
// Echter Matchbeginn (nach Aufwärmphase oder mp_restartgame): Zählung beginnt neu.
|
||||||
|
|||||||
+135
-16
@@ -1,5 +1,6 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
import { BEST_OF, computeVetoState, resolveAction, VetoError } from './veto.js';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
import { formatCode, hashToken, normalizeCode, randomCode, randomId, randomToken, safeEqual } from './security.js';
|
||||||
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
import { applyLogLines, initialLiveState, seriesScore, splitLogBody } from './liveScore.js';
|
||||||
import { trimLiveState } from './rounds.js';
|
import { trimLiveState } from './rounds.js';
|
||||||
@@ -41,9 +42,13 @@ function cleanSteamId(value) {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHAT_MAX_LENGTH = 300;
|
||||||
|
const CHAT_HISTORY = 200;
|
||||||
|
|
||||||
export class MatchService extends EventEmitter {
|
export class MatchService extends EventEmitter {
|
||||||
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
|
constructor({ db, pools, config, notifier = { enabled: false, send: async () => {} } }) {
|
||||||
super();
|
super();
|
||||||
|
this.chatRate = new Map();
|
||||||
this.db = db;
|
this.db = db;
|
||||||
this.pools = pools;
|
this.pools = pools;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -150,7 +155,8 @@ export class MatchService extends EventEmitter {
|
|||||||
if (!formatInfo?.supported) {
|
if (!formatInfo?.supported) {
|
||||||
throw new HttpError(400, `${String(format).toUpperCase()} wird für ${pool.label} nicht unterstützt`, 'unsupported_format');
|
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 nameA = cleanName(teamA, 'Name von Team A');
|
||||||
const nameB = cleanName(teamB, 'Name von Team B');
|
const nameB = cleanName(teamB, 'Name von Team B');
|
||||||
if (nameA.toLowerCase() === nameB.toLowerCase()) {
|
if (nameA.toLowerCase() === nameB.toLowerCase()) {
|
||||||
@@ -326,7 +332,11 @@ export class MatchService extends EventEmitter {
|
|||||||
|
|
||||||
// ---------- Veto ----------
|
// ---------- 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.require(viewer, 'organizer');
|
||||||
this.db.transaction(() => {
|
this.db.transaction(() => {
|
||||||
const match = this.getMatch(viewer.matchId);
|
const match = this.getMatch(viewer.matchId);
|
||||||
@@ -335,11 +345,16 @@ export class MatchService extends EventEmitter {
|
|||||||
if (!readiness.A.ready || !readiness.B.ready) {
|
if (!readiness.A.ready || !readiness.B.ready) {
|
||||||
throw new HttpError(409, 'Beide Teams müssen vollständig und bereit sein', 'teams_not_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
|
this.db
|
||||||
.prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?")
|
.prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?")
|
||||||
.run(firstTeam, this.now(), this.now(), match.id);
|
.run(firstTeam, this.now(), this.now(), match.id);
|
||||||
const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
|
const how = 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.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
|
||||||
this.notify('veto_start', match.id, {
|
this.notify('veto_start', match.id, {
|
||||||
title: '🎮 Veto gestartet',
|
title: '🎮 Veto gestartet',
|
||||||
@@ -541,6 +556,9 @@ export class MatchService extends EventEmitter {
|
|||||||
if (!server || !safeEqual(String(key ?? ''), server.config_key)) {
|
if (!server || !safeEqual(String(key ?? ''), server.config_key)) {
|
||||||
throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden');
|
throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden');
|
||||||
}
|
}
|
||||||
|
// Erst zählen, wenn die Map eingerichtet ist. Während des Einrichtens kommen noch Zeilen vom vorigen
|
||||||
|
// Spiel auf dem Server (z. B. dem Ende der Messerrunde), die nicht zur neuen Map gehören.
|
||||||
|
if (server.status !== 'configured') return;
|
||||||
this.applyLiveLog(matchId, splitLogBody(body));
|
this.applyLiveLog(matchId, splitLogBody(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +643,74 @@ export class MatchService extends EventEmitter {
|
|||||||
.run(matchId, match.current_map_number);
|
.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]);
|
||||||
|
// Gelegentlich alte Einträge anderer Spieler entfernen
|
||||||
|
if (this.chatRate.size > 500) {
|
||||||
|
for (const [k, times] of this.chatRate) if (!times.some((t) => now - t < 10_000)) this.chatRate.delete(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
/** Abgeschlossenes Match ins Archiv legen bzw. zurückholen. */
|
||||||
setArchived(matchId, archived) {
|
setArchived(matchId, archived) {
|
||||||
const match = this.getMatch(matchId);
|
const match = this.getMatch(matchId);
|
||||||
@@ -742,8 +828,30 @@ export class MatchService extends EventEmitter {
|
|||||||
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
|
* Lobbys und kürzlich beendete Matches knapp. Enthält nur, was Zuschauer sehen dürfen –
|
||||||
* keine Codes, keine Verbindungsdaten.
|
* keine Codes, keine Verbindungsdaten.
|
||||||
*/
|
*/
|
||||||
streamOverview({ resultsWithinMs = 3 * 3_600_000 } = {}) {
|
/** Schlüssel für die Stream-Übersicht nur eines Matches, abgeleitet aus STREAM_KEY (ohne ihn preiszugeben). */
|
||||||
const rows = this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
matchStreamKey(matchId) {
|
||||||
|
const mac = crypto.createHmac('sha256', this.config.streamKey).update(`stream:${matchId}`).digest('base64url').slice(0, 22);
|
||||||
|
return `${matchId}.${mac}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft einen Stream-Schlüssel: { matchId: null } = alle Matches, { matchId } = nur dieses Match, null = kein Zugang.
|
||||||
|
* Ohne STREAM_KEY ist die Übersicht offen.
|
||||||
|
*/
|
||||||
|
streamAccess(key) {
|
||||||
|
if (!this.config.streamKey) return { matchId: null };
|
||||||
|
const value = String(key ?? '');
|
||||||
|
if (safeEqual(value, this.config.streamKey)) return { matchId: null };
|
||||||
|
const dot = value.lastIndexOf('.');
|
||||||
|
const matchId = dot > 0 ? value.slice(0, dot) : '';
|
||||||
|
if (matchId && safeEqual(value, this.matchStreamKey(matchId))) return { matchId };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamOverview({ resultsWithinMs = 3 * 3_600_000, matchId = null } = {}) {
|
||||||
|
const rows = matchId
|
||||||
|
? this.db.prepare('SELECT * FROM matches WHERE id = ?').all(matchId)
|
||||||
|
: this.db.prepare('SELECT * FROM matches ORDER BY created_at DESC LIMIT 200').all();
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
const active = [];
|
const active = [];
|
||||||
const upcoming = [];
|
const upcoming = [];
|
||||||
@@ -947,6 +1055,7 @@ export class MatchService extends EventEmitter {
|
|||||||
seriesScore: seriesScore(seriesRows),
|
seriesScore: seriesScore(seriesRows),
|
||||||
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
|
maxRounds: match.mode === 'wingman' ? this.config.match.maxRoundsWingman : this.config.match.maxRoundsCompetitive,
|
||||||
server: this.serverView(match, viewer),
|
server: this.serverView(match, viewer),
|
||||||
|
chat: this.chatView(match, viewer),
|
||||||
events: publicView
|
events: publicView
|
||||||
? []
|
? []
|
||||||
: this.db
|
: this.db
|
||||||
@@ -965,7 +1074,7 @@ export class MatchService extends EventEmitter {
|
|||||||
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
||||||
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
||||||
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
|
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
|
||||||
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`,
|
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.matchStreamKey(match.id))}` : ''}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -986,25 +1095,25 @@ export class MatchService extends EventEmitter {
|
|||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
|
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
|
||||||
};
|
};
|
||||||
|
// Messerrunde auf dem Match-Server: Spieler bekommen die Verbindungsdaten schon in der Lobby
|
||||||
|
const knife = match.status === 'lobby' && row.knife_at && row.status === 'ready';
|
||||||
|
if (match.status === 'lobby' && match.veto_start === 'knife') {
|
||||||
|
// failed: alle automatischen Versuche fehlgeschlagen, der Organisator kann es von Hand wiederholen
|
||||||
|
const failed = !knife && row.status === 'ready' && String(row.message ?? '').startsWith('Messerrunde konnte nicht');
|
||||||
|
view.knife = { ready: !!knife, phase: knife ? row.knife_phase : null, failed };
|
||||||
|
}
|
||||||
if (viewer.isOrganizer) {
|
if (viewer.isOrganizer) {
|
||||||
// Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen")
|
// Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen")
|
||||||
view.requestedAt = row.started_at ?? null;
|
view.requestedAt = row.started_at ?? null;
|
||||||
if (row.host) view.address = { host: row.host, port: row.port, tvPort: row.tv_port };
|
if (row.host) view.address = { host: row.host, port: row.port, tvPort: row.tv_port };
|
||||||
}
|
}
|
||||||
const configured = row.status === 'configured';
|
const configured = row.status === 'configured';
|
||||||
|
if (knife && viewer.role !== 'public' && (viewer.isOrganizer || viewer.playerId)) view.connect = this.connectInfo(row);
|
||||||
if (!configured || viewer.role === 'public') return view;
|
if (!configured || viewer.role === 'public') return view;
|
||||||
|
|
||||||
const isTeam = viewer.isOrganizer || !!viewer.playerId;
|
const isTeam = viewer.isOrganizer || !!viewer.playerId;
|
||||||
const isSpectatorView = viewer.isOrganizer || viewer.isSpectator;
|
const isSpectatorView = viewer.isOrganizer || viewer.isSpectator;
|
||||||
if (isTeam) {
|
if (isTeam) view.connect = this.connectInfo(row);
|
||||||
view.connect = {
|
|
||||||
host: row.host,
|
|
||||||
port: row.port,
|
|
||||||
password: row.server_password,
|
|
||||||
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
|
|
||||||
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (isSpectatorView && this.config.match.tvEnabled) {
|
if (isSpectatorView && this.config.match.tvEnabled) {
|
||||||
view.gotv = {
|
view.gotv = {
|
||||||
host: row.host,
|
host: row.host,
|
||||||
@@ -1016,4 +1125,14 @@ export class MatchService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connectInfo(row) {
|
||||||
|
return {
|
||||||
|
host: row.host,
|
||||||
|
port: row.port,
|
||||||
|
password: row.server_password,
|
||||||
|
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
|
||||||
|
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+46
-10
@@ -1,5 +1,8 @@
|
|||||||
import { Server } from 'socket.io';
|
import { Server } from 'socket.io';
|
||||||
import { safeEqual } from './security.js';
|
// Abfragen pro Verbindung: kurzer Schwall erlaubt, danach 2 pro Sekunde
|
||||||
|
const SOCKET_BURST = 20;
|
||||||
|
const SOCKET_REFILL = 2;
|
||||||
|
const SOCKET_REFILL_MS = 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
|
* Echtzeit-Kanal. Clients senden nie Aktionen über den Socket – alle Änderungen laufen über die
|
||||||
@@ -54,7 +57,13 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
let streamTimer = null;
|
let streamTimer = null;
|
||||||
const broadcastStream = () => {
|
const broadcastStream = () => {
|
||||||
clearTimeout(streamTimer);
|
clearTimeout(streamTimer);
|
||||||
streamTimer = setTimeout(() => io.to('stream-overview').emit('stream:overview', service.streamOverview()), 200);
|
streamTimer = setTimeout(async () => {
|
||||||
|
io.to('stream-overview').emit('stream:overview', service.streamOverview());
|
||||||
|
// Zugänge mit Match-Schlüssel bekommen nur ihr Match
|
||||||
|
for (const s of await io.in('stream-match').fetchSockets()) {
|
||||||
|
s.emit('stream:overview', service.streamOverview({ matchId: s.data.streamMatch }));
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
service.on('changed', (matchId) => {
|
service.on('changed', (matchId) => {
|
||||||
@@ -64,25 +73,47 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('overview:subscribe', () => {
|
// Jede Nachricht kommt ungeprüft vom Client: Fehler im Handler dürfen nie das Backend beenden,
|
||||||
|
// und Abfragen sind pro Verbindung begrenzt (jede baut einen vollständigen Zustand).
|
||||||
|
let budget = SOCKET_BURST;
|
||||||
|
const refill = setInterval(() => { budget = Math.min(SOCKET_BURST, budget + SOCKET_REFILL); }, SOCKET_REFILL_MS);
|
||||||
|
const on = (event, handler) => socket.on(event, (payload) => {
|
||||||
|
if (budget <= 0) {
|
||||||
|
socket.emit('rate_limited', { error: 'Zu viele Anfragen – bitte kurz warten' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
budget -= 1;
|
||||||
|
try {
|
||||||
|
handler(payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[socket] ${event}: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
on('overview:subscribe', () => {
|
||||||
socket.join('overview');
|
socket.join('overview');
|
||||||
socket.emit('overview', { matches: service.listMatches() });
|
socket.emit('overview', { matches: service.listMatches() });
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('stream:subscribe', ({ key } = {}) => {
|
on('stream:subscribe', ({ key }) => {
|
||||||
if (config.streamKey && !safeEqual(String(key ?? ''), config.streamKey)) {
|
const access = service.streamAccess(typeof key === 'string' ? key : '');
|
||||||
|
if (!access) {
|
||||||
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
|
socket.emit('stream:error', { error: 'Für die Stream-Übersicht wird der Stream-Schlüssel benötigt' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
socket.join('stream-overview');
|
socket.leave('stream-overview');
|
||||||
socket.emit('stream:overview', service.streamOverview());
|
socket.leave('stream-match');
|
||||||
|
// Mit einem Match-Schlüssel gibt es nur die Daten dieses einen Matches
|
||||||
|
socket.data.streamMatch = access.matchId ?? null;
|
||||||
|
socket.join(access.matchId ? 'stream-match' : 'stream-overview');
|
||||||
|
socket.emit('stream:overview', service.streamOverview({ matchId: access.matchId }));
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
|
on('match:subscribe', ({ matchId, token, playerToken }) => {
|
||||||
if (typeof matchId !== 'string') return;
|
if (typeof matchId !== 'string' || matchId.length > 64) return;
|
||||||
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
||||||
const str = (v) => (typeof v === 'string' ? v : null);
|
const str = (v) => (typeof v === 'string' ? v : null);
|
||||||
socket.data = { matchId, token: str(token), playerToken: str(playerToken) };
|
socket.data = { ...socket.data, matchId, token: str(token), playerToken: str(playerToken) };
|
||||||
try {
|
try {
|
||||||
service.getMatch(matchId);
|
service.getMatch(matchId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -107,7 +138,12 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
|
clearInterval(refill);
|
||||||
|
try {
|
||||||
if (socket.data?.matchId) leave(socket.data.matchId);
|
if (socket.data?.matchId) leave(socket.data.matchId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[socket] disconnect: ${err.message}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+28
-6
@@ -47,20 +47,42 @@ export function safeEqual(a, b) {
|
|||||||
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlermeldung ohne Zugangsdaten. Node schreibt bei einem fehlgeschlagenen Prozess die ganze Befehlszeile
|
||||||
|
* in die Meldung ("Command failed: docker run -e CS2_RCONPW=…"). Die Zeile fällt weg, übrig bleibt die
|
||||||
|
* Ausgabe des Programms; Werte von Variablen wie *_PW, *PASSWORD*, *TOKEN* werden zusätzlich geschwärzt,
|
||||||
|
* ebenso alle in `secrets` genannten Werte.
|
||||||
|
*/
|
||||||
|
export function safeErrorMessage(err, secrets = []) {
|
||||||
|
const stderr = String(err?.stderr ?? '').trim();
|
||||||
|
let text = stderr || String(err?.message ?? err ?? 'Unbekannter Fehler');
|
||||||
|
text = text.replace(/^Command failed:.*$/gm, '').trim() || 'Befehl fehlgeschlagen';
|
||||||
|
text = text.replace(/\b([A-Z0-9_]*(?:PW|PASS|PASSWORD|TOKEN|SECRET|KEY)[A-Z0-9_]*)=("[^"]*"|\S+)/gi, '$1=***');
|
||||||
|
for (const s of secrets) if (s && String(s).length >= 4) text = text.split(String(s)).join('***');
|
||||||
|
return text.slice(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
/** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */
|
/** Einfacher In-Memory-Ratenbegrenzer (Fenster pro Schlüssel). */
|
||||||
export function createRateLimiter({ windowMs, max }) {
|
export function createRateLimiter({ windowMs, max, maxKeys = 10_000 }) {
|
||||||
const hits = new Map();
|
const hits = new Map();
|
||||||
return function check(key) {
|
let lastSweep = Date.now();
|
||||||
|
function check(key) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
// Abgelaufene Fenster regelmäßig entfernen, unabhängig davon, ob der Schlüssel neu ist
|
||||||
|
if (now - lastSweep > windowMs) {
|
||||||
|
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
|
||||||
|
lastSweep = now;
|
||||||
|
}
|
||||||
const entry = hits.get(key);
|
const entry = hits.get(key);
|
||||||
if (!entry || now - entry.start > windowMs) {
|
if (!entry || now - entry.start > windowMs) {
|
||||||
|
// Obergrenze für den Speicher: ältesten Eintrag verdrängen (Map hält die Einfügereihenfolge)
|
||||||
|
if (!entry && hits.size >= maxKeys) hits.delete(hits.keys().next().value);
|
||||||
hits.set(key, { start: now, count: 1 });
|
hits.set(key, { start: now, count: 1 });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
entry.count += 1;
|
entry.count += 1;
|
||||||
if (hits.size > 10_000) {
|
|
||||||
for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k);
|
|
||||||
}
|
|
||||||
return entry.count <= max;
|
return entry.count <= max;
|
||||||
};
|
}
|
||||||
|
check.size = () => hits.size;
|
||||||
|
return check;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Hinweise für den Ingame-Chat (RCON "say"). Absender ist im Spiel "Console".
|
||||||
|
// Reine Textbausteine ohne Server-Zugriff, damit sie sich mit Beispielen testen lassen.
|
||||||
|
|
||||||
|
const MAX_LENGTH = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Farbcodes für den CS2-Chat (am Server geprüft). Ein Code färbt alles bis zum nächsten Code.
|
||||||
|
* Sie kommen ausschließlich aus den Textbausteinen hier, nie aus Eingaben.
|
||||||
|
*/
|
||||||
|
export const COLOR = {
|
||||||
|
white: '\x01',
|
||||||
|
red: '\x02',
|
||||||
|
green: '\x04',
|
||||||
|
yellow: '\x09',
|
||||||
|
blue: '\x0B',
|
||||||
|
gold: '\x10',
|
||||||
|
};
|
||||||
|
const ALLOWED_CODES = new Set(Object.values(COLOR));
|
||||||
|
// CS2 färbt wie im Spiel: CT blau, T gold
|
||||||
|
const SIDE_COLOR = { CT: COLOR.blue, T: COLOR.gold };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingaben (Teamnamen, Map-Namen) für den Chat: ohne Anführungszeichen, Semikolon, Zeilenumbrüche und ohne
|
||||||
|
* Steuerzeichen, sodass sich weder Befehle anhängen noch eigene Farben einbauen lassen.
|
||||||
|
* Doppelpunkt und Komma bleiben für Spielstände erlaubt.
|
||||||
|
*/
|
||||||
|
export function chatText(text, maxLength = MAX_LENGTH) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/["';\\\r\n\t]/g, '')
|
||||||
|
.replace(/[^\p{L}\p{N} .,:_\-+#!?()[\]|/&]/gu, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dauer für den Chat: unter zwei Minuten in Sekunden, sonst gerundet in Minuten. */
|
||||||
|
function duration(seconds) {
|
||||||
|
const s = Math.max(0, Math.round(seconds));
|
||||||
|
return s < 120 ? `${s} s` : `etwa ${Math.round(s / 60)} Minuten`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gefärbter Teil einer Zeile; danach geht es in Weiß weiter. */
|
||||||
|
const paint = (color, text) => `${color}${text}${COLOR.white}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konsolenbefehle für eine Liste von Hinweisen. Erlaubt sind nur die Farbcodes aus COLOR; alles, was einen
|
||||||
|
* weiteren Befehl einleiten könnte, fällt weg. Das Leerzeichen am Anfang braucht CS2, damit der erste Farbcode wirkt.
|
||||||
|
*/
|
||||||
|
export function sayCommands(lines) {
|
||||||
|
return lines
|
||||||
|
// Kein trim(): Gelb (\x09) und Blau (\x0B) zählen für JavaScript als Leerraum und gingen sonst verloren
|
||||||
|
.map((line) => [...String(line ?? '')]
|
||||||
|
.filter((ch) => ALLOWED_CODES.has(ch) || !/[\x00-\x1F\x7F"';\\]/.test(ch))
|
||||||
|
.join(''))
|
||||||
|
.filter((line) => line.replace(/[\x00-\x1F]/g, '').trim())
|
||||||
|
.map((line) => `say ${line}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const other = (team) => (team === 'A' ? 'B' : 'A');
|
||||||
|
const name = (teams, team) => chatText(teams[team], 32) || 'Team';
|
||||||
|
|
||||||
|
/** Teamname in der Farbe seiner Startseite. */
|
||||||
|
const sided = (teams, team, ctTeam) => (ctTeam ? paint(SIDE_COLOR[team === ctTeam ? 'CT' : 'T'], name(teams, team)) : name(teams, team));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hinweise nach dem Laden einer Map: welche Map, wer sie gewählt hat, wer welche Seite startet,
|
||||||
|
* Serienstand und dass die Aufwärmphase bis zur Freigabe läuft.
|
||||||
|
* @param {object} p
|
||||||
|
* @param {{A: string, B: string}} p.teams Teamnamen
|
||||||
|
* @param {{number: number, mapName: string, pickedBy: 'A'|'B'|null, sideChosenBy: 'A'|'B'|null, ctTeam: 'A'|'B'|null}} p.map
|
||||||
|
* @param {number} p.total Maps in der Serie
|
||||||
|
* @param {{A: number, B: number}} p.series Serienstand vor dieser Map
|
||||||
|
*/
|
||||||
|
export function mapLoadedLines({ teams, map, total, series }) {
|
||||||
|
const lines = [];
|
||||||
|
const mapName = chatText(map.mapName, 32);
|
||||||
|
const head = total > 1 ? `Map ${map.number} von ${total}: ${mapName}` : `Map: ${mapName}`;
|
||||||
|
lines.push(map.pickedBy ? `${head} - gewählt von ${name(teams, map.pickedBy)}` : `${head} - Entscheidungsmap`);
|
||||||
|
if (map.ctTeam) {
|
||||||
|
const t = other(map.ctTeam);
|
||||||
|
const chooser = map.sideChosenBy ? `${name(teams, map.sideChosenBy)} hat die Seite gewählt: ` : '';
|
||||||
|
lines.push(`${chooser}${sided(teams, map.ctTeam, map.ctTeam)} startet als ${paint(COLOR.blue, 'CT')}, ${sided(teams, t, map.ctTeam)} als ${paint(COLOR.gold, 'T')}`);
|
||||||
|
}
|
||||||
|
if (total > 1) lines.push(`Serienstand: ${name(teams, 'A')} ${series.A}:${series.B} ${name(teams, 'B')}`);
|
||||||
|
lines.push(paint(COLOR.yellow, 'Aufwärmphase - das Match startet, sobald der Organisator es freigibt'));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hinweis beim Start des Matches (Aufwärmphase beendet). */
|
||||||
|
export function matchStartLines({ teams, ctTeam }) {
|
||||||
|
const live = paint(COLOR.green, 'Match live!');
|
||||||
|
if (!ctTeam) return [`${live} ${name(teams, 'A')} gegen ${name(teams, 'B')} - viel Glück, have fun`];
|
||||||
|
return [`${live} ${sided(teams, ctTeam, ctTeam)} (CT) gegen ${sided(teams, other(ctTeam), ctTeam)} (T) - viel Glück, have fun`];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hinweise am Ende einer Map: Sieger mit Stand, Serienstand und was als Nächstes passiert.
|
||||||
|
* @param {object} p
|
||||||
|
* @param {{mapName: string, number: number, winner: 'A'|'B'|null, score: {A: number, B: number}}} p.map
|
||||||
|
* @param {{A: number, B: number}} p.series Serienstand nach dieser Map
|
||||||
|
* @param {'A'|'B'|null} p.decided Sieger der Serie, wenn entschieden
|
||||||
|
* @param {string|null} p.nextMapName nächste Map, wenn es eine gibt
|
||||||
|
* @param {number} p.autoAdvanceSeconds Wartezeit bis zum automatischen Laden (< 0 = aus)
|
||||||
|
* @param {number} p.total Maps in der Serie
|
||||||
|
* @param {number} [p.tvDelaySeconds] CSTV-Verzögerung (0 = keine). CS2 wechselt die Map erst, wenn CSTV durch ist
|
||||||
|
* (tv_delaymapchange), und am Serienende läuft der Server so lange weiter.
|
||||||
|
* @param {number} [p.shutdownSeconds] am Serienende: so lange läuft der Server noch (null = unbekannt)
|
||||||
|
*/
|
||||||
|
export function mapEndLines({ teams, map, series, decided, nextMapName, autoAdvanceSeconds, total, tvDelaySeconds = 0, shutdownSeconds = null }) {
|
||||||
|
const lines = [];
|
||||||
|
const score = `${map.score.A}:${map.score.B}`;
|
||||||
|
const mapName = chatText(map.mapName, 32);
|
||||||
|
lines.push(map.winner
|
||||||
|
? `Map ${map.number} (${mapName}) geht an ${paint(COLOR.green, name(teams, map.winner))} mit ${score}`
|
||||||
|
: `Map ${map.number} (${mapName}) endet ${score}`);
|
||||||
|
if (decided) {
|
||||||
|
const s = decided === 'A' ? `${series.A}:${series.B}` : `${series.B}:${series.A}`;
|
||||||
|
lines.push(total > 1
|
||||||
|
? `${paint(COLOR.green, `${name(teams, decided)} gewinnt die Serie ${s}`)} - danke fürs Spielen!`
|
||||||
|
: 'Danke fürs Spielen!');
|
||||||
|
if (tvDelaySeconds > 0) {
|
||||||
|
lines.push(shutdownSeconds
|
||||||
|
? `Der Server bleibt noch ${duration(shutdownSeconds)} für die CSTV-Übertragung online und schaltet sich dann ab.`
|
||||||
|
: 'Der Server bleibt für die CSTV-Übertragung noch online und schaltet sich dann ab.');
|
||||||
|
}
|
||||||
|
} else if (nextMapName) {
|
||||||
|
const next = chatText(nextMapName, 32);
|
||||||
|
lines.push(`Serienstand: ${name(teams, 'A')} ${series.A}:${series.B} ${name(teams, 'B')}`);
|
||||||
|
// Die Map wechselt frühestens nach der Wartezeit der App und erst, wenn CSTV das Ende übertragen hat
|
||||||
|
const wait = Math.max(autoAdvanceSeconds, tvDelaySeconds > 0 ? tvDelaySeconds : 0);
|
||||||
|
const viaTv = tvDelaySeconds > autoAdvanceSeconds ? ' (nach der CSTV-Übertragung)' : '';
|
||||||
|
lines.push(autoAdvanceSeconds >= 0
|
||||||
|
? `Nächste Map: ${next}, wird in ${viaTv && wait < 120 ? 'etwa ' : ''}${duration(wait)} geladen${viaTv} - bleibt auf dem Server`
|
||||||
|
: `Nächste Map: ${next} - bleibt auf dem Server`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
@@ -59,6 +59,12 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
|
|||||||
`mp_maxrounds ${isWingman ? settings.maxRoundsWingman : settings.maxRoundsCompetitive}`,
|
`mp_maxrounds ${isWingman ? settings.maxRoundsWingman : settings.maxRoundsCompetitive}`,
|
||||||
`mp_overtime_enable ${settings.overtime ? 1 : 0}`,
|
`mp_overtime_enable ${settings.overtime ? 1 : 0}`,
|
||||||
'mp_match_can_clinch 1',
|
'mp_match_can_clinch 1',
|
||||||
|
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
|
||||||
|
'mp_endmatch_votenextmap 0',
|
||||||
|
// Strenge Prüfung der Delta-Ticks (Standard 2) trennt Spieler und CSTV ohne echten Grund
|
||||||
|
'sv_deltaticks_enforce 0',
|
||||||
|
// Nach dem Laden noch einmal: die Spielmodus-Datei der Map setzt tv_delay beim Laden zurück
|
||||||
|
`tv_delay ${Number(settings.tvDelaySeconds)}`,
|
||||||
'mp_autoteambalance 0',
|
'mp_autoteambalance 0',
|
||||||
'mp_limitteams 0',
|
'mp_limitteams 0',
|
||||||
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
|
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
|
// Er simuliert nur Startdauer und Konfiguration, damit der Ablauf ohne Hosting getestet werden kann.
|
||||||
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
|
// Das Flag simulated=true sorgt dafür, dass das Frontend den Server ausdrücklich als Simulation kennzeichnet.
|
||||||
|
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
import { backupPrefix, parseBackupFiles } from '../rounds.js';
|
import { backupPrefix, parseBackupFiles } from '../rounds.js';
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -51,6 +52,20 @@ export class DemoServerAdapter {
|
|||||||
|
|
||||||
async loadRoundBackup() {}
|
async loadRoundBackup() {}
|
||||||
|
|
||||||
|
/** Simulierte Konsolenausgabe, damit die Konsolen-Ansicht ohne echten Server testbar ist. */
|
||||||
|
streamLogs(handle, { intervalMs = 1000 } = {}) {
|
||||||
|
const stdout = new PassThrough();
|
||||||
|
const stderr = new PassThrough();
|
||||||
|
let n = 0;
|
||||||
|
stdout.write('Demo-Adapter: kein echter Container, die Ausgabe ist simuliert\n');
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
n += 1;
|
||||||
|
stdout.write(`[Demo] Server läuft (${handle.map ?? 'de_demo'}), Zeile ${n}\n`);
|
||||||
|
}, intervalMs);
|
||||||
|
const proc = { stdout, stderr, kill() { clearInterval(timer); stdout.end(); stderr.end(); proc.onExit?.(0); }, on(evt, fn) { if (evt === 'close') proc.onExit = fn; return proc; } };
|
||||||
|
return proc;
|
||||||
|
}
|
||||||
|
|
||||||
async sendCommands(handle, commands) {
|
async sendCommands(handle, commands) {
|
||||||
return commands.map(() => '(Demo – nicht ausgeführt)');
|
return commands.map(() => '(Demo – nicht ausgeführt)');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile, spawn } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
import { configureViaRcon, listRoundBackups, loadRoundBackup, rconStatus } from './rconConfigure.js';
|
||||||
import { runRcon } from './rcon.js';
|
import { runRcon } from './rcon.js';
|
||||||
import { sanitizeConsoleText } from './cs2Config.js';
|
import { sanitizeConsoleText } from './cs2Config.js';
|
||||||
|
import { safeErrorMessage } from '../security.js';
|
||||||
|
|
||||||
const exec = promisify(execFile);
|
const exec = promisify(execFile);
|
||||||
|
|
||||||
@@ -33,6 +34,74 @@ export function countSlots(host, used) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ereignisse des Docker-Hosts, auf Deutsch. exec_* (Healthchecks fremder Container) und Mount-Rauschen fallen weg,
|
||||||
|
// weil sie nichts aussagen und Befehlszeilen mit Zugangsdaten enthalten können.
|
||||||
|
const EVENT_LABELS = {
|
||||||
|
container: {
|
||||||
|
create: 'angelegt', start: 'gestartet', restart: 'neu gestartet', stop: 'gestoppt', kill: 'abgebrochen',
|
||||||
|
die: 'beendet', destroy: 'entfernt', pause: 'angehalten', unpause: 'fortgesetzt', oom: 'kein Speicher mehr',
|
||||||
|
'health_status: healthy': 'gesund', 'health_status: unhealthy': 'ungesund',
|
||||||
|
},
|
||||||
|
image: { pull: 'geladen', delete: 'gelöscht', untag: 'Markierung entfernt', tag: 'markiert' },
|
||||||
|
volume: { create: 'angelegt', destroy: 'gelöscht' },
|
||||||
|
};
|
||||||
|
const EVENT_TYPES = { container: 'Container', image: 'Image', volume: 'Volume' };
|
||||||
|
|
||||||
|
/** Eine JSON-Zeile von `docker events` lesbar machen. null = Ereignis wird nicht angezeigt. */
|
||||||
|
export function formatDockerEvent(line) {
|
||||||
|
let e;
|
||||||
|
try {
|
||||||
|
e = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = EVENT_LABELS[e.Type]?.[e.Action];
|
||||||
|
if (!label) return null;
|
||||||
|
const attrs = e.Actor?.Attributes ?? {};
|
||||||
|
const time = new Date((e.time ?? 0) * 1000).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
const name = attrs.name ?? e.Actor?.ID?.slice(0, 12) ?? '';
|
||||||
|
const app = attrs['cs2-mapveto'] === '1' ? ' [Mapveto]' : '';
|
||||||
|
const extra = e.Action === 'die' && attrs.exitCode != null ? ` (Exit-Code ${attrs.exitCode})`
|
||||||
|
: e.Action === 'kill' && attrs.signal ? ` (Signal ${attrs.signal})`
|
||||||
|
: e.Type === 'container' && attrs.image ? ` (${attrs.image})` : '';
|
||||||
|
return `${time} ${EVENT_TYPES[e.Type]} ${name}${app} ${label}${extra}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skript für den Update-Container: dasselbe SteamCMD-Update wie beim Serverstart des Images (entry.sh),
|
||||||
|
* nur ohne danach den Server zu starten. Vorher und nachher wird die Build-Nummer ausgegeben.
|
||||||
|
*/
|
||||||
|
export const GAMEFILES_UPDATE_SCRIPT = [
|
||||||
|
'f=/home/steam/cs2-dedicated/steamapps/appmanifest_730.acf',
|
||||||
|
'b() { grep -m1 \'"buildid"\' "$f" 2>/dev/null | tr -dc 0-9; }',
|
||||||
|
'echo "BUILD_BEFORE=$(b)"',
|
||||||
|
'bash /home/steam/steamcmd/steamcmd.sh +force_install_dir /home/steam/cs2-dedicated +@bClientTryRequestManifestWithoutCode 1'
|
||||||
|
+ ' +login anonymous +app_update 730 -beta public +quit',
|
||||||
|
'rc=$?',
|
||||||
|
'echo "BUILD_AFTER=$(b)"',
|
||||||
|
// StateFlags 4 = vollständig installiert; SteamCMD meldet auch bei Abbruch oft Exit-Code 0
|
||||||
|
'echo "STATE_AFTER=$(grep -m1 \'"StateFlags"\' "$f" 2>/dev/null | tr -dc 0-9)"',
|
||||||
|
'exit $rc',
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
/** Fortschritt und Build-Nummern aus der Ausgabe des Update-Containers lesen. */
|
||||||
|
export function parseUpdateOutput(text, into = {}) {
|
||||||
|
for (const line of String(text).split(/\r?\n/)) {
|
||||||
|
const before = /^BUILD_BEFORE=(\d*)/.exec(line);
|
||||||
|
const after = /^BUILD_AFTER=(\d*)/.exec(line);
|
||||||
|
const state = /^STATE_AFTER=(\d*)/.exec(line);
|
||||||
|
const error = /(Error!.*|ERROR!.*)/.exec(line);
|
||||||
|
const progress = /progress:\s*([\d.]+)/.exec(line);
|
||||||
|
if (before) into.buildBefore = before[1] || null;
|
||||||
|
else if (after) into.buildAfter = after[1] || null;
|
||||||
|
else if (state) into.stateAfter = state[1] || null;
|
||||||
|
else if (error) into.error = error[1].trim().slice(0, 200);
|
||||||
|
else if (progress) into.progress = Number(progress[1]);
|
||||||
|
if (line.trim()) into.lastLine = line.trim().slice(0, 200);
|
||||||
|
}
|
||||||
|
return into;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
|
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
|
||||||
const PORT_BUSY = /port is already allocated|address already in use/i;
|
const PORT_BUSY = /port is already allocated|address already in use/i;
|
||||||
const DISK_CACHE_MS = 60_000;
|
const DISK_CACHE_MS = 60_000;
|
||||||
@@ -73,14 +142,24 @@ export class DockerServerAdapter {
|
|||||||
this.runDocker = runDocker; // nur für Tests
|
this.runDocker = runDocker; // nur für Tests
|
||||||
this.lanMode = !options.gslt;
|
this.lanMode = !options.gslt;
|
||||||
this.startQueue = Promise.resolve();
|
this.startQueue = Promise.resolve();
|
||||||
|
// Starts, die gerade warten oder deren Container noch SteamCMD laufen lässt
|
||||||
|
this.pendingStarts = 0;
|
||||||
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
|
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
|
||||||
}
|
}
|
||||||
|
|
||||||
async docker(args, timeout = 60_000) {
|
async docker(args, timeout = 60_000) {
|
||||||
if (this.runDocker) return this.runDocker(args, this.options.name);
|
if (this.runDocker) return this.runDocker(args, this.options.name);
|
||||||
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||||
|
try {
|
||||||
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
|
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
|
||||||
return stdout.trim();
|
return stdout.trim();
|
||||||
|
} catch (err) {
|
||||||
|
// Die Befehlszeile enthält bei "docker run" die Passwörter: nie weiterreichen
|
||||||
|
const clean = new Error(safeErrorMessage(err));
|
||||||
|
clean.stderr = safeErrorMessage({ message: err.stderr ?? '' });
|
||||||
|
clean.code = err.code;
|
||||||
|
throw clean;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allocatePorts(blocked = new Set()) {
|
allocatePorts(blocked = new Set()) {
|
||||||
@@ -101,8 +180,13 @@ export class DockerServerAdapter {
|
|||||||
async start(args) {
|
async start(args) {
|
||||||
// In die Warteschlange: erst starten, wenn der vorige Container seinen Update-Check hinter sich hat.
|
// In die Warteschlange: erst starten, wenn der vorige Container seinen Update-Check hinter sich hat.
|
||||||
const previous = this.startQueue;
|
const previous = this.startQueue;
|
||||||
let release;
|
let releaseQueue;
|
||||||
this.startQueue = new Promise((resolve) => { release = resolve; });
|
this.startQueue = new Promise((resolve) => { releaseQueue = resolve; });
|
||||||
|
this.pendingStarts += 1;
|
||||||
|
const release = () => {
|
||||||
|
this.pendingStarts -= 1;
|
||||||
|
releaseQueue();
|
||||||
|
};
|
||||||
await previous;
|
await previous;
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
@@ -164,11 +248,11 @@ export class DockerServerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async launch({ matchId, mode, secrets, teamNames, serverName }, blocked = new Set()) {
|
async launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, blocked = new Set()) {
|
||||||
const { port, tvPort } = this.allocatePorts(blocked);
|
const { port, tvPort } = this.allocatePorts(blocked);
|
||||||
const lan = (this.options.lanPorts ?? []).includes(port);
|
const lan = (this.options.lanPorts ?? []).includes(port);
|
||||||
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
|
if (lan && blocked.size < 10 && !(await this.hostPortsFree([port, tvPort]))) {
|
||||||
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
|
return this.launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, new Set([...blocked, port, tvPort]));
|
||||||
}
|
}
|
||||||
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
|
const container = `cs2mv-${matchId.replace(/[^a-zA-Z0-9_-]/g, '')}`.toLowerCase();
|
||||||
const env = {
|
const env = {
|
||||||
@@ -182,9 +266,13 @@ export class DockerServerAdapter {
|
|||||||
CS2_GAMETYPE: '0',
|
CS2_GAMETYPE: '0',
|
||||||
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
CS2_GAMEMODE: mode === 'wingman' ? '2' : '1',
|
||||||
CS2_SERVER_HIBERNATE: '0',
|
CS2_SERVER_HIBERNATE: '0',
|
||||||
// Ohne diesen Startparameter legt CS2 keine Rundensicherungen an (Rollback)
|
// -allow_round_backup_files: ohne ihn legt CS2 keine Rundensicherungen an (Rollback).
|
||||||
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files',
|
// sv_deltaticks_enforce 0: sonst trennt CS2 Spieler und CSTV mit NETWORK_DISCONNECT_BADDELTATICK
|
||||||
|
CS2_ADDITIONAL_ARGS: '-allow_round_backup_files +sv_deltaticks_enforce 0',
|
||||||
TV_ENABLE: '1',
|
TV_ENABLE: '1',
|
||||||
|
// Das Image schreibt TV_DELAY beim Start in alle gamemode_*.cfg (Standard 0), und die laufen bei jedem
|
||||||
|
// Laden einer Map. Ohne den Wert hier stünde CSTV nach jedem Mapwechsel wieder ohne Verzögerung da.
|
||||||
|
TV_DELAY: String(Math.max(0, Number(tvDelaySeconds) || 0)),
|
||||||
TV_PORT: String(tvPort),
|
TV_PORT: String(tvPort),
|
||||||
TV_PW: secrets.tvPassword,
|
TV_PW: secrets.tvPassword,
|
||||||
TV_RELAY_PW: secrets.tvPassword,
|
TV_RELAY_PW: secrets.tvPassword,
|
||||||
@@ -216,7 +304,7 @@ export class DockerServerAdapter {
|
|||||||
// Port von einem fremden Dienst belegt (die App kennt nur ihre eigenen Server): mit anderen Ports erneut versuchen
|
// 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;
|
if (!PORT_BUSY.test(`${err.message} ${err.stderr ?? ''}`) || blocked.size >= 10) throw err;
|
||||||
await this.docker(['rm', '-f', container]).catch(() => {});
|
await this.docker(['rm', '-f', container]).catch(() => {});
|
||||||
return this.launch({ matchId, mode, secrets, teamNames, serverName }, new Set([...blocked, port, tvPort]));
|
return this.launch({ matchId, mode, secrets, teamNames, serverName, tvDelaySeconds }, new Set([...blocked, port, tvPort]));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -278,6 +366,18 @@ export class DockerServerAdapter {
|
|||||||
return runRcon(this.target(handle), commands);
|
return runRcon(this.target(handle), commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ereignisse des Docker-Hosts live (`docker events`), ab `sinceMinutes` in der Vergangenheit. Nur lesen. */
|
||||||
|
streamEvents({ sinceMinutes = 120 } = {}) {
|
||||||
|
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||||
|
return spawn(this.options.bin, ['events', '--since', `${Math.max(0, sinceMinutes)}m`, '--format', '{{json .}}'], { env, windowsHide: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Konsolenausgabe des Containers live: `docker logs --follow`. Liefert den Kindprozess (stdout/stderr, kill). */
|
||||||
|
streamLogs(handle, { tail = 200 } = {}) {
|
||||||
|
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||||
|
return spawn(this.options.bin, ['logs', '--follow', '--tail', String(tail), handle.container], { env, windowsHide: true });
|
||||||
|
}
|
||||||
|
|
||||||
async stop(handle) {
|
async stop(handle) {
|
||||||
await this.docker(['rm', '-f', handle.container]).catch(() => {});
|
await this.docker(['rm', '-f', handle.container]).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -288,15 +388,17 @@ export class DockerServerAdapter {
|
|||||||
* immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen.
|
* immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen.
|
||||||
*/
|
*/
|
||||||
export class DockerHostPool {
|
export class DockerHostPool {
|
||||||
constructor(options, { getUsedPorts, runDocker } = {}) {
|
constructor(options, { getUsedPorts, getStarting, runDocker } = {}) {
|
||||||
this.name = 'docker';
|
this.name = 'docker';
|
||||||
this.simulated = false;
|
this.simulated = false;
|
||||||
this.depsGetUsedPorts = getUsedPorts;
|
this.depsGetUsedPorts = getUsedPorts;
|
||||||
|
this.depsGetStarting = getStarting;
|
||||||
this.runDocker = runDocker;
|
this.runDocker = runDocker;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.diskCache = new Map();
|
this.diskCache = new Map();
|
||||||
this.diskInflight = new Map();
|
this.diskInflight = new Map();
|
||||||
this.pull = { running: false, startedAt: null, finishedAt: null, results: [] };
|
this.pull = { running: false, startedAt: null, finishedAt: null, results: [] };
|
||||||
|
this.files = { running: false, startedAt: null, finishedAt: null, results: [] };
|
||||||
this.setHosts(options.hosts);
|
this.setHosts(options.hosts);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +487,83 @@ export class DockerHostPool {
|
|||||||
return this.pull;
|
return this.pull;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CS2-Spieldateien im Volume jedes Hosts per SteamCMD aktualisieren (eigener, kurzlebiger Container).
|
||||||
|
* Hosts in `skip` (z. B. mit laufenden Servern) werden ausgelassen, damit niemandem die Dateien unter
|
||||||
|
* einem laufenden Server ausgetauscht werden.
|
||||||
|
*/
|
||||||
|
async updateGameFiles({ skip = new Map() } = {}) {
|
||||||
|
if (this.files.running) throw new Error('Die Spieldateien werden bereits aktualisiert');
|
||||||
|
const hosts = this.hosts.map((h) => h.name);
|
||||||
|
this.files = {
|
||||||
|
running: true,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
finishedAt: null,
|
||||||
|
results: hosts.map((name) => (skip.has(name)
|
||||||
|
? { name, state: 'skipped', message: skip.get(name) }
|
||||||
|
: { name, state: 'running', message: 'SteamCMD prüft auf Updates …', progress: null })),
|
||||||
|
};
|
||||||
|
await Promise.all(this.files.results.filter((r) => r.state === 'running').map((r) => this.runFileUpdate(r)));
|
||||||
|
this.files.running = false;
|
||||||
|
this.files.finishedAt = Date.now();
|
||||||
|
return this.files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async runFileUpdate(result) {
|
||||||
|
const host = this.hosts.find((h) => h.name === result.name);
|
||||||
|
const args = [
|
||||||
|
'run', '--rm', '--name', `cs2mv-update-${host.name.replace(/[^a-zA-Z0-9_.-]/g, '')}`, '--label', 'cs2-mapveto-update=1',
|
||||||
|
'-v', `${host.volume}:/home/steam/cs2-dedicated`, '--entrypoint', 'bash', this.options.image, '-c', GAMEFILES_UPDATE_SCRIPT,
|
||||||
|
];
|
||||||
|
const info = {};
|
||||||
|
const finish = (exitOk, error) => {
|
||||||
|
const { buildBefore: from, buildAfter: to } = info;
|
||||||
|
// Nur "ok", wenn Steam die App als vollständig installiert meldet
|
||||||
|
const complete = info.stateAfter == null || info.stateAfter === '4';
|
||||||
|
const ok = exitOk && complete && !info.error;
|
||||||
|
if (!ok && exitOk && !error) {
|
||||||
|
error = info.error ?? `nicht vollständig installiert (StateFlags ${info.stateAfter}), bitte erneut starten`;
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
Object.assign(result, {
|
||||||
|
state: 'ok',
|
||||||
|
progress: 100,
|
||||||
|
message: from && to && from === to ? `bereits aktuell (Build ${to})` : to ? `aktualisiert auf Build ${to}${from ? ` (vorher ${from})` : ''}` : 'aktualisiert',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(result, { state: 'failed', message: String(error || info.lastLine || 'SteamCMD-Update fehlgeschlagen').slice(0, 200) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (this.runDocker) {
|
||||||
|
// Tests: Ausgabe kommt gesammelt zurück
|
||||||
|
try {
|
||||||
|
parseUpdateOutput(await this.runDocker(args, host.name), info);
|
||||||
|
finish(true);
|
||||||
|
} catch (err) {
|
||||||
|
finish(false, err.message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const env = host.dockerHost ? { ...process.env, DOCKER_HOST: host.dockerHost } : process.env;
|
||||||
|
const proc = spawn(this.options.bin, args, { env, windowsHide: true });
|
||||||
|
const onData = (chunk) => {
|
||||||
|
parseUpdateOutput(chunk.toString('utf8'), info);
|
||||||
|
if (info.progress != null) Object.assign(result, { progress: info.progress, message: `Update läuft ${Math.floor(info.progress)} %` });
|
||||||
|
};
|
||||||
|
proc.stdout.on('data', onData);
|
||||||
|
proc.stderr.on('data', onData);
|
||||||
|
// Ein großes Update dauert, aber nicht ewig
|
||||||
|
const timer = setTimeout(() => proc.kill(), 90 * 60_000);
|
||||||
|
proc.on('error', (err) => { clearTimeout(timer); finish(false, err.message); resolve(); });
|
||||||
|
proc.on('close', (code) => { clearTimeout(timer); finish(code === 0, code === null ? 'abgebrochen (Zeitlimit)' : null); resolve(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filesStatus() {
|
||||||
|
return this.files;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Freier Speicher auf einem Host, per SSH: dort, wo das Gameserver-Volume liegt. Ergebnis wird eine Minute lang
|
* Freier Speicher auf einem Host, per SSH: dort, wo das Gameserver-Volume liegt. Ergebnis wird eine Minute lang
|
||||||
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
|
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
|
||||||
@@ -431,17 +610,36 @@ export class DockerHostPool {
|
|||||||
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
|
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
pickHost() {
|
/** Hosts, auf denen gerade die Spieldateien aktualisiert werden: dort startet kein neuer Server. */
|
||||||
|
updatingHosts() {
|
||||||
|
return new Set(this.files.running ? this.files.results.filter((r) => r.state === 'running').map((r) => r.name) : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Server auf einem Host gerade hochfahren: Starts in der Warteschlange dieses Prozesses, und
|
||||||
|
* (nach einem Neustart der App) Server, die laut Datenbank noch starten. Ein Container lädt beim Start
|
||||||
|
* per SteamCMD, nach einem großen CS2-Update dauert das lange; weitere Starts dort müssten so lange warten.
|
||||||
|
*/
|
||||||
|
startingOn(host, exclude) {
|
||||||
|
const queued = this.adapters.get(host.name)?.pendingStarts ?? 0;
|
||||||
|
const stored = this.depsGetStarting?.(host.publicHost, exclude) ?? 0;
|
||||||
|
return Math.max(queued, stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
pickHost(exclude) {
|
||||||
|
const updating = this.updatingHosts();
|
||||||
const best = this.hosts
|
const best = this.hosts
|
||||||
.map((h) => ({ host: h, free: this.freeSlots(h) }))
|
.filter((h) => !updating.has(h.name))
|
||||||
|
.map((h) => ({ host: h, free: this.freeSlots(h), starting: this.startingOn(h, exclude) }))
|
||||||
.filter((x) => x.free > 0)
|
.filter((x) => x.free > 0)
|
||||||
.sort((a, b) => b.free - a.free)[0];
|
// Zuerst Hosts, auf denen nichts hochfährt, dann die mit den meisten freien Plätzen
|
||||||
if (!best) throw new Error('Kein Docker-Host mit freien Ports');
|
.sort((a, b) => a.starting - b.starting || b.free - a.free)[0];
|
||||||
|
if (!best) throw new Error(updating.size ? 'Kein Docker-Host frei, auf den anderen werden gerade die Spieldateien aktualisiert' : 'Kein Docker-Host mit freien Ports');
|
||||||
return best.host;
|
return best.host;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(args) {
|
async start(args) {
|
||||||
return this.adapters.get(this.pickHost().name).start(args);
|
return this.adapters.get(this.pickHost(args?.matchId).name).start(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
adapterFor(handle) {
|
adapterFor(handle) {
|
||||||
@@ -474,6 +672,15 @@ export class DockerHostPool {
|
|||||||
return this.adapterFor(handle).sendCommands(handle, commands);
|
return this.adapterFor(handle).sendCommands(handle, commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
streamLogs(handle, options) {
|
||||||
|
return this.adapterFor(handle).streamLogs(handle, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ereignisse eines Hosts nach Namen; null, wenn es den Host nicht gibt. */
|
||||||
|
streamEvents(name, options) {
|
||||||
|
return this.adapters.get(name)?.streamEvents(options) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async stop(handle) {
|
async stop(handle) {
|
||||||
return this.adapterFor(handle).stop(handle);
|
return this.adapterFor(handle).stop(handle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// leer wird, setzt er sich von selbst zurück. Fällt ein Server aus, startet ihn die App neu.
|
// 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 { HttpError } from '../matchService.js';
|
||||||
import { randomPassword } from '../security.js';
|
import { randomPassword, safeErrorMessage } from '../security.js';
|
||||||
|
|
||||||
const SLOW_POLL_MS = 10_000;
|
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
|
// Nach so vielen Ausfällen in Folge gibt die App auf, damit ein kaputter Host nicht endlos neu startet
|
||||||
@@ -13,14 +13,15 @@ const MAX_RESTARTS = 5;
|
|||||||
* Konsolenbefehle für eine Messerrunde: nur Messer, kein Geld, eine Runde, danach automatisch neues Spiel.
|
* Konsolenbefehle für eine Messerrunde: nur Messer, kein Geld, eine Runde, danach automatisch neues Spiel.
|
||||||
* `pool` bestimmt den Spielmodus (Competitive 5v5 oder Wingman 2v2), `warmupSeconds` die Zeit zum Verbinden.
|
* `pool` bestimmt den Spielmodus (Competitive 5v5 oder Wingman 2v2), `warmupSeconds` die Zeit zum Verbinden.
|
||||||
*/
|
*/
|
||||||
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90 }) {
|
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90, password = '', endlessWarmup = false }) {
|
||||||
return {
|
return {
|
||||||
map,
|
map,
|
||||||
beforeMapChange: [
|
beforeMapChange: [
|
||||||
`game_type ${Number(pool.gameType)}`,
|
`game_type ${Number(pool.gameType)}`,
|
||||||
`game_mode ${Number(pool.gameMode)}`,
|
`game_mode ${Number(pool.gameMode)}`,
|
||||||
'sv_password ""',
|
`sv_password "${password}"`,
|
||||||
'tv_enable 0',
|
// Auf dem Match-Server (mit Passwort) bleibt CSTV, wie es ist: das Match braucht es danach
|
||||||
|
...(password ? [] : ['tv_enable 0']),
|
||||||
`changelevel ${map}`,
|
`changelevel ${map}`,
|
||||||
],
|
],
|
||||||
afterMapChange: [
|
afterMapChange: [
|
||||||
@@ -29,6 +30,10 @@ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, w
|
|||||||
// Eine Runde entscheidet, danach startet das Spiel von selbst neu (für die nächsten Teams)
|
// Eine Runde entscheidet, danach startet das Spiel von selbst neu (für die nächsten Teams)
|
||||||
'mp_maxrounds 1',
|
'mp_maxrounds 1',
|
||||||
'mp_match_can_clinch 1',
|
'mp_match_can_clinch 1',
|
||||||
|
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
|
||||||
|
'mp_endmatch_votenextmap 0',
|
||||||
|
// Strenge Prüfung der Delta-Ticks (Standard 2) trennt Spieler und CSTV ohne echten Grund
|
||||||
|
'sv_deltaticks_enforce 0',
|
||||||
'mp_match_end_restart 1',
|
'mp_match_end_restart 1',
|
||||||
'mp_match_restart_delay 10',
|
'mp_match_restart_delay 10',
|
||||||
'mp_halftime 0',
|
'mp_halftime 0',
|
||||||
@@ -56,15 +61,38 @@ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, w
|
|||||||
'mp_autoteambalance 0',
|
'mp_autoteambalance 0',
|
||||||
'mp_limitteams 0',
|
'mp_limitteams 0',
|
||||||
'mp_autokick 0',
|
'mp_autokick 0',
|
||||||
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde
|
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde. Endlos: bis der Organisator sie startet.
|
||||||
'mp_do_warmup_period 1',
|
'mp_do_warmup_period 1',
|
||||||
`mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`,
|
`mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`,
|
||||||
'mp_warmup_pausetimer 0',
|
`mp_warmup_pausetimer ${endlessWarmup ? 1 : 0}`,
|
||||||
'mp_warmup_start',
|
'mp_warmup_start',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Messer-Einstellungen auf CS2-Standard zurück, bevor auf demselben Server das Match eingerichtet wird.
|
||||||
|
* Was die Match-Konfiguration selbst setzt (Startgeld, Kaufzeit, Rundenzeit …), steht dort.
|
||||||
|
*/
|
||||||
|
export const KNIFE_UNDO_COMMANDS = [
|
||||||
|
'mp_ct_default_secondary weapon_hkp2000',
|
||||||
|
'mp_t_default_secondary weapon_glock',
|
||||||
|
'mp_ct_default_primary ""',
|
||||||
|
'mp_t_default_primary ""',
|
||||||
|
'mp_ct_default_grenades ""',
|
||||||
|
'mp_t_default_grenades ""',
|
||||||
|
'mp_give_player_c4 1',
|
||||||
|
'mp_maxmoney 16000',
|
||||||
|
'mp_afterroundmoney 0',
|
||||||
|
'mp_free_armor 0',
|
||||||
|
'mp_weapons_allow_map_placed 1',
|
||||||
|
'mp_death_drop_gun 1',
|
||||||
|
'mp_death_drop_grenade 2',
|
||||||
|
'mp_match_end_restart 0',
|
||||||
|
'mp_roundtime_defuse 1.92',
|
||||||
|
'mp_roundtime_hostage 1.92',
|
||||||
|
];
|
||||||
|
|
||||||
/** Befehle, mit denen ein laufender Messer-Server für die nächsten Teams zurückgesetzt wird. */
|
/** Befehle, mit denen ein laufender Messer-Server für die nächsten Teams zurückgesetzt wird. */
|
||||||
export const KNIFE_RESET_COMMANDS = ['mp_warmup_start'];
|
export const KNIFE_RESET_COMMANDS = ['mp_warmup_start'];
|
||||||
|
|
||||||
@@ -181,7 +209,14 @@ export class KnifeServers {
|
|||||||
mode: row.mode,
|
mode: row.mode,
|
||||||
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
|
secrets: { rconPassword: row.rcon_password, serverPassword: '', tvPassword: randomPassword(12) },
|
||||||
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
|
serverName: `${prefix ? `${prefix} | ` : ''}${row.name} ${size ? `${size}v${size} ` : ''}(Seitenwahl)`,
|
||||||
|
// Gleicher Wert wie bei den Match-Servern: das Image schreibt ihn in die gemeinsamen Spielmodus-Dateien
|
||||||
|
tvDelaySeconds: this.config.match?.tvDelaySeconds,
|
||||||
});
|
});
|
||||||
|
// Während des Starts entfernt: Container gleich wieder abbauen
|
||||||
|
if (!this.row(id)) {
|
||||||
|
await this.adapter.stop(result.handle).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.update(id, {
|
this.update(id, {
|
||||||
status: 'starting',
|
status: 'starting',
|
||||||
message: 'Container läuft, Spielserver lädt',
|
message: 'Container läuft, Spielserver lädt',
|
||||||
@@ -193,8 +228,9 @@ export class KnifeServers {
|
|||||||
started_at: Date.now(),
|
started_at: Date.now(),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${err.message}` });
|
const message = safeErrorMessage(err, [row.rcon_password]);
|
||||||
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${err.message}`, 'start_failed');
|
if (this.row(id)) this.update(id, { status: 'failed', message: `Start fehlgeschlagen: ${message}` });
|
||||||
|
throw new HttpError(502, `Messer-Server konnte nicht gestartet werden: ${message}`, 'start_failed');
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.delete(id);
|
this.busy.delete(id);
|
||||||
}
|
}
|
||||||
@@ -253,6 +289,11 @@ export class KnifeServers {
|
|||||||
|
|
||||||
/** Ausgefallenen Server neu starten, damit immer Messer-Server laufen. */
|
/** Ausgefallenen Server neu starten, damit immer Messer-Server laufen. */
|
||||||
async recover(row, reason) {
|
async recover(row, reason) {
|
||||||
|
// Während des Spieldateien-Updates nicht neu starten (zählt auch nicht als Fehlversuch)
|
||||||
|
if (this.adapter.filesStatus?.().running) {
|
||||||
|
if (row.message !== 'Wartet auf das Spieldateien-Update') this.update(row.id, { status: 'starting', message: 'Wartet auf das Spieldateien-Update' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (row.restarts >= MAX_RESTARTS) {
|
if (row.restarts >= MAX_RESTARTS) {
|
||||||
if (row.status !== 'failed') this.update(row.id, { status: 'failed', message: `Ausgefallen, ${MAX_RESTARTS} Neustarts ohne Erfolg: ${reason ?? ''}`.trim() });
|
if (row.status !== 'failed') this.update(row.id, { status: 'failed', message: `Ausgefallen, ${MAX_RESTARTS} Neustarts ohne Erfolg: ${reason ?? ''}`.trim() });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -75,9 +75,17 @@ export class RconClient {
|
|||||||
|
|
||||||
onData(chunk) {
|
onData(chunk) {
|
||||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||||
const { packets, rest } = decodePackets(this.buffer);
|
let decoded;
|
||||||
this.buffer = rest;
|
try {
|
||||||
for (const packet of packets) for (const listener of this.listeners) listener(packet);
|
decoded = decodePackets(this.buffer);
|
||||||
|
} catch (err) {
|
||||||
|
// Kaputtes Paket: Verbindung trennen, laufende Befehle scheitern über "close" – nie das Backend beenden
|
||||||
|
this.buffer = Buffer.alloc(0);
|
||||||
|
this.socket?.destroy(new RconError(err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.buffer = decoded.rest;
|
||||||
|
for (const packet of decoded.packets) for (const listener of this.listeners) listener(packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticate() {
|
authenticate() {
|
||||||
@@ -105,15 +113,18 @@ export class RconClient {
|
|||||||
const id = this.nextId++;
|
const id = this.nextId++;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
let answered = false;
|
||||||
let quietTimer = null;
|
let quietTimer = null;
|
||||||
const timer = setTimeout(() => finish(), this.timeoutMs);
|
// Ohne jede Antwort ist der Befehl nicht bestätigt: als Fehler melden statt als Erfolg
|
||||||
|
const timer = setTimeout(() => finish(answered ? null : new RconError(`keine Antwort auf "${cmd.split(' ')[0]}"`)), this.timeoutMs);
|
||||||
const listener = (packet) => {
|
const listener = (packet) => {
|
||||||
if (packet.id !== id) return;
|
if (packet.id !== id) return;
|
||||||
|
answered = true;
|
||||||
body += packet.body;
|
body += packet.body;
|
||||||
clearTimeout(quietTimer);
|
clearTimeout(quietTimer);
|
||||||
quietTimer = setTimeout(finish, quietMs);
|
quietTimer = setTimeout(finish, quietMs);
|
||||||
};
|
};
|
||||||
const onClose = () => finish(body ? null : new RconError('RCON-Verbindung wurde geschlossen'));
|
const onClose = () => finish(answered ? null : new RconError('RCON-Verbindung wurde geschlossen'));
|
||||||
const finish = (err) => {
|
const finish = (err) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
clearTimeout(quietTimer);
|
clearTimeout(quietTimer);
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ export async function configureViaRcon(target, plan, { loadTimeoutMs = 120_000,
|
|||||||
try {
|
try {
|
||||||
await runRcon(target, plan.beforeMapChange);
|
await runRcon(target, plan.beforeMapChange);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Beim changelevel trennt der Server die RCON-Verbindung teilweise – das ist kein Fehler.
|
// Beim changelevel trennt der Server die RCON-Verbindung teilweise oder antwortet erst nach dem Laden –
|
||||||
if (!/geschlossen/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
|
// beides ist kein Fehler, ob die Map lädt, prüft die Schleife unten.
|
||||||
|
if (!/geschlossen|keine Antwort auf "changelevel"/.test(err.message)) throw new Error(`Befehle konnten nicht gesendet werden: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deadline = Date.now() + loadTimeoutMs;
|
const deadline = Date.now() + loadTimeoutMs;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
||||||
|
import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js';
|
||||||
|
import { mapEndLines, mapLoadedLines, matchStartLines, sayCommands } from './chatAnnouncements.js';
|
||||||
|
import { seriesScore } from '../liveScore.js';
|
||||||
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
||||||
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
||||||
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||||||
import { randomPassword } from '../security.js';
|
import { randomPassword, safeErrorMessage } from '../security.js';
|
||||||
|
|
||||||
export const SERVER_STATUS = {
|
export const SERVER_STATUS = {
|
||||||
starting: 'Server startet',
|
starting: 'Server startet',
|
||||||
@@ -17,6 +20,14 @@ export const SERVER_STATUS = {
|
|||||||
|
|
||||||
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
|
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
|
||||||
const SLOW_POLL_MS = 30_000;
|
const SLOW_POLL_MS = 30_000;
|
||||||
|
// Messerrunde: so oft wird das Einrichten versucht, bevor der Organisator selbst neu starten muss
|
||||||
|
const KNIFE_RETRIES = 3;
|
||||||
|
const KNIFE_RETRY_DELAY_MS = 10_000;
|
||||||
|
// Hinweise im Ingame-Chat während der Aufwärmphase wiederholen (für Spieler, die später verbinden)
|
||||||
|
const WARMUP_REPEAT_MS = 60_000;
|
||||||
|
const WARMUP_REPEATS = 10;
|
||||||
|
// Nach der CSTV-Verzögerung noch so lange laufen lassen, damit auch der Endstand übertragen wird
|
||||||
|
const CSTV_GRACE_MS = 15_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
|
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
|
||||||
@@ -28,6 +39,14 @@ export class ServerManager {
|
|||||||
this.busy = new Set();
|
this.busy = new Set();
|
||||||
this.simulator = new DemoMatchSimulator();
|
this.simulator = new DemoMatchSimulator();
|
||||||
this.autoAdvance = new Map();
|
this.autoAdvance = new Map();
|
||||||
|
// Fehlversuche beim Einrichten der Messerrunde je Match (für die automatische Wiederholung)
|
||||||
|
this.knifeAttempts = new Map();
|
||||||
|
this.knifeRetryDelayMs = KNIFE_RETRY_DELAY_MS;
|
||||||
|
this.warmupRepeatMs = WARMUP_REPEAT_MS;
|
||||||
|
this.warmupTimers = new Map();
|
||||||
|
// Nach Match-Ende: Server laufen bis zum Ende der CSTV-Übertragung weiter (matchId -> Timer)
|
||||||
|
this.closeTimers = new Map();
|
||||||
|
this.cstvGraceMs = CSTV_GRACE_MS;
|
||||||
this.lastPoll = new Map();
|
this.lastPoll = new Map();
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
}
|
}
|
||||||
@@ -35,7 +54,7 @@ export class ServerManager {
|
|||||||
init() {
|
init() {
|
||||||
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
|
this.service.on('created', (id) => this.provision(id).catch((err) => this.logger.error(err)));
|
||||||
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
|
this.service.on('vetoFinished', (id) => this.maybeConfigure(id));
|
||||||
this.service.on('closed', (id) => this.stop(id, 'Match beendet').catch((err) => this.logger.error(err)));
|
this.service.on('closed', (id) => this.stopAfterClose(id));
|
||||||
this.service.on('mapEnded', (e) => this.onMapEnded(e));
|
this.service.on('mapEnded', (e) => this.onMapEnded(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +64,7 @@ export class ServerManager {
|
|||||||
*/
|
*/
|
||||||
onMapEnded({ matchId, number, decided, hasNext }) {
|
onMapEnded({ matchId, number, decided, hasNext }) {
|
||||||
const seconds = this.config.autoAdvanceSeconds;
|
const seconds = this.config.autoAdvanceSeconds;
|
||||||
|
this.announceMapEnd(matchId, number, decided, hasNext);
|
||||||
if (seconds < 0) return;
|
if (seconds < 0) return;
|
||||||
clearTimeout(this.autoAdvance.get(matchId));
|
clearTimeout(this.autoAdvance.get(matchId));
|
||||||
const finish = Boolean(decided) || !hasNext;
|
const finish = Boolean(decided) || !hasNext;
|
||||||
@@ -125,12 +145,13 @@ export class ServerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
|
/** Löscht ein Match samt Server-Datensatz. Ein laufender Server wird vorher freigegeben. */
|
||||||
async deleteMatch(matchId) {
|
async deleteMatch(matchId, reason = 'Match vom Admin gelöscht') {
|
||||||
this.service.getMatch(matchId);
|
this.service.getMatch(matchId);
|
||||||
clearTimeout(this.autoAdvance.get(matchId));
|
clearTimeout(this.autoAdvance.get(matchId));
|
||||||
this.autoAdvance.delete(matchId);
|
this.autoAdvance.delete(matchId);
|
||||||
this.simulator.stop(matchId);
|
this.simulator.stop(matchId);
|
||||||
await this.stop(matchId, 'Match vom Admin gelöscht');
|
this.knifeAttempts.delete(matchId);
|
||||||
|
await this.stop(matchId, reason);
|
||||||
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
|
this.db.prepare('DELETE FROM matches WHERE id = ?').run(matchId);
|
||||||
this.service.emit('changed', matchId);
|
this.service.emit('changed', matchId);
|
||||||
}
|
}
|
||||||
@@ -147,6 +168,10 @@ export class ServerManager {
|
|||||||
this.autoAdvance.clear();
|
this.autoAdvance.clear();
|
||||||
this.timers.forEach(clearInterval);
|
this.timers.forEach(clearInterval);
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
|
for (const t of this.warmupTimers.values()) clearInterval(t);
|
||||||
|
this.warmupTimers.clear();
|
||||||
|
for (const t of this.closeTimers.values()) clearTimeout(t);
|
||||||
|
this.closeTimers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
row(matchId) {
|
row(matchId) {
|
||||||
@@ -257,6 +282,14 @@ export class ServerManager {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Server (Matches und Messer-Server), die auf einem Host gerade hochfahren. `exclude`: der, der gerade startet. */
|
||||||
|
startingOn(host, exclude = null) {
|
||||||
|
return this.db.prepare(`
|
||||||
|
SELECT (SELECT COUNT(*) FROM servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND match_id IS NOT @exclude)
|
||||||
|
+ (SELECT COUNT(*) FROM knife_servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND id IS NOT @exclude) AS n
|
||||||
|
`).get({ host, exclude }).n;
|
||||||
|
}
|
||||||
|
|
||||||
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
|
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
|
||||||
usedPortsOn(host) {
|
usedPortsOn(host) {
|
||||||
const used = new Set();
|
const used = new Set();
|
||||||
@@ -305,7 +338,7 @@ export class ServerManager {
|
|||||||
VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL,
|
VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL,
|
||||||
@rcon, @server, @tv, @key, @attempts, @now, @now)
|
@rcon, @server, @tv, @key, @attempts, @now, @now)
|
||||||
ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated,
|
ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated,
|
||||||
status = 'starting', message = excluded.message, handle = NULL, host = NULL, port = NULL, tv_port = NULL,
|
status = 'starting', message = excluded.message, handle = NULL, knife_at = NULL, knife_phase = NULL, host = NULL, port = NULL, tv_port = NULL,
|
||||||
rcon_password = excluded.rcon_password, server_password = excluded.server_password,
|
rcon_password = excluded.rcon_password, server_password = excluded.server_password,
|
||||||
tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts,
|
tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts,
|
||||||
started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL,
|
started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL,
|
||||||
@@ -327,13 +360,23 @@ export class ServerManager {
|
|||||||
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet');
|
this.service.log(matchId, 'server', this.adapter.simulated ? 'Demo-Server (Simulation) wird vorbereitet' : 'Server wird gestartet');
|
||||||
|
|
||||||
const match = this.service.getMatch(matchId);
|
const match = this.service.getMatch(matchId);
|
||||||
|
const attempt = (previous?.attempts ?? 0) + 1;
|
||||||
try {
|
try {
|
||||||
const result = await this.adapter.start({
|
const result = await this.adapter.start({
|
||||||
matchId,
|
matchId,
|
||||||
mode: match.mode,
|
mode: match.mode,
|
||||||
secrets,
|
secrets,
|
||||||
teamNames: { A: match.team_a_name, B: match.team_b_name },
|
teamNames: { A: match.team_a_name, B: match.team_b_name },
|
||||||
|
tvDelaySeconds: this.config.match.tvDelaySeconds,
|
||||||
});
|
});
|
||||||
|
// Während des (oft langen) Starts gestoppt, neu angefordert oder gelöscht: den neuen Container
|
||||||
|
// gleich wieder abbauen, sonst liefe er ohne Überwachung weiter
|
||||||
|
const current = this.row(matchId);
|
||||||
|
if (!current || current.status !== 'starting' || current.attempts !== attempt) {
|
||||||
|
this.logger.warn?.(`[server] ${matchId}: Start kam nach dem Stopp an, Container wird wieder entfernt`);
|
||||||
|
await this.adapter.stop(result.handle, { resetPassword: randomPassword(16) }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.update(matchId, {
|
this.update(matchId, {
|
||||||
handle: JSON.stringify(result.handle),
|
handle: JSON.stringify(result.handle),
|
||||||
host: result.host,
|
host: result.host,
|
||||||
@@ -342,8 +385,9 @@ export class ServerManager {
|
|||||||
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
|
message: this.adapter.simulated ? 'Simulierter Start läuft' : 'Server fährt hoch',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.update(matchId, { status: 'start_failed', message: err.message });
|
const message = safeErrorMessage(err, Object.values(secrets));
|
||||||
this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${err.message}`);
|
if (this.row(matchId)?.status === 'starting') this.update(matchId, { status: 'start_failed', message });
|
||||||
|
if (this.row(matchId)) this.service.log(matchId, 'server', `Serverstart fehlgeschlagen: ${message}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.delete(matchId);
|
this.busy.delete(matchId);
|
||||||
@@ -406,12 +450,154 @@ export class ServerManager {
|
|||||||
maybeConfigure(matchId) {
|
maybeConfigure(matchId) {
|
||||||
const match = this.service.getMatch(matchId);
|
const match = this.service.getMatch(matchId);
|
||||||
const row = this.row(matchId);
|
const row = this.row(matchId);
|
||||||
|
// Seitenwahl per Messerrunde: derselbe Server dient vor dem Veto als Messer-Server
|
||||||
|
if (this.wantsKnife(match, row) && !row.knife_at) {
|
||||||
|
this.configureKnife(matchId).catch((err) => this.logger.error(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden
|
// Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden
|
||||||
if (['server_setup', 'live'].includes(match.status) && row?.status === 'ready') {
|
if (['server_setup', 'live'].includes(match.status) && row?.status === 'ready') {
|
||||||
this.configure(matchId).catch((err) => this.logger.error(err));
|
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Hinweise im Ingame-Chat ----------
|
||||||
|
|
||||||
|
teamNames(matchId) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
return { A: match.team_a_name, B: match.team_b_name };
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSeriesMap(matchId) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
return this.service.seriesMaps(matchId).find((m) => m.number === match.current_map_number) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schickt Hinweise per "say" an den eingerichteten Server. Fehler stören den Ablauf nie. */
|
||||||
|
announce(matchId, lines) {
|
||||||
|
const row = this.row(matchId);
|
||||||
|
if (!row?.handle || row.status !== 'configured' || !this.adapter.sendCommands || !lines.length) return;
|
||||||
|
this.adapter.sendCommands(JSON.parse(row.handle), sayCommands(lines))
|
||||||
|
.catch((err) => this.logger.warn?.(`[chat] Hinweis an ${matchId} nicht gesendet: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nach dem Laden einer Map: Map, Wahl, Seiten, Serienstand. Wiederholt sich, solange die Aufwärmphase läuft. */
|
||||||
|
announceMapLoaded(matchId, number) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
const maps = this.service.seriesMaps(matchId);
|
||||||
|
const map = maps.find((m) => m.number === number);
|
||||||
|
if (!map) return;
|
||||||
|
const lines = mapLoadedLines({
|
||||||
|
teams: this.teamNames(matchId),
|
||||||
|
map: { number, mapName: this.service.mapName(match, map.map), pickedBy: map.picked_by, sideChosenBy: map.side_chosen_by, ctTeam: map.ct_team },
|
||||||
|
total: maps.length,
|
||||||
|
series: seriesScore(maps),
|
||||||
|
});
|
||||||
|
this.announce(matchId, lines);
|
||||||
|
this.stopWarmupRepeat(matchId);
|
||||||
|
let left = WARMUP_REPEATS;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const current = this.currentSeriesMap(matchId);
|
||||||
|
const live = current?.live ? JSON.parse(current.live) : null;
|
||||||
|
if (left-- <= 0 || current?.number !== number || live?.warmupEnded || this.row(matchId)?.status !== 'configured') {
|
||||||
|
this.stopWarmupRepeat(matchId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.announce(matchId, lines);
|
||||||
|
}, this.warmupRepeatMs);
|
||||||
|
this.warmupTimers.set(matchId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopWarmupRepeat(matchId) {
|
||||||
|
clearInterval(this.warmupTimers.get(matchId));
|
||||||
|
this.warmupTimers.delete(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Am Map-Ende: Sieger, Serienstand, nächste Map oder Seriensieger. */
|
||||||
|
announceMapEnd(matchId, number, decided, hasNext) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
const maps = this.service.seriesMaps(matchId);
|
||||||
|
const map = maps.find((m) => m.number === number);
|
||||||
|
if (!map) return;
|
||||||
|
const next = hasNext && !decided ? maps.find((m) => m.number === number + 1) : null;
|
||||||
|
const tvDelay = this.config.match.tvEnabled ? Number(this.config.match.tvDelaySeconds) || 0 : 0;
|
||||||
|
this.announce(matchId, mapEndLines({
|
||||||
|
teams: this.teamNames(matchId),
|
||||||
|
map: { number, mapName: this.service.mapName(match, map.map), winner: map.winner, score: { A: map.score_a, B: map.score_b } },
|
||||||
|
series: seriesScore(maps),
|
||||||
|
decided,
|
||||||
|
nextMapName: next ? this.service.mapName(match, next.map) : null,
|
||||||
|
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
|
||||||
|
total: maps.length,
|
||||||
|
tvDelaySeconds: tvDelay,
|
||||||
|
// Serienende: Match schließt nach der Wartezeit, der Server läuft dann noch CSTV-Verzögerung + Puffer
|
||||||
|
shutdownSeconds: decided && this.config.autoAdvanceSeconds >= 0 && tvDelay > 0
|
||||||
|
? this.config.autoAdvanceSeconds + tvDelay + this.cstvGraceMs / 1000
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match in der Lobby mit Messerrunde und hochgefahrenem Server. */
|
||||||
|
wantsKnife(match, row) {
|
||||||
|
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messerrunde auf dem Match-Server einrichten: Aufwärmphase ohne Ende, Passwort des Matches. */
|
||||||
|
async configureKnife(matchId) {
|
||||||
|
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||||||
|
this.busy.add(matchId);
|
||||||
|
try {
|
||||||
|
const row = this.row(matchId);
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
const pool = this.service.poolFor(match);
|
||||||
|
const map = pool.maps.some((m) => m.id === this.config.knife?.map) ? this.config.knife.map : pool.maps[0].id;
|
||||||
|
this.update(matchId, { message: 'Messerrunde wird eingerichtet' });
|
||||||
|
try {
|
||||||
|
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map, pool, password: row.server_password, endlessWarmup: true }));
|
||||||
|
} catch (err) {
|
||||||
|
// Das erste Laden einer Map dauert nach einer frischen Installation oft länger: von selbst noch einmal versuchen
|
||||||
|
const attempt = (this.knifeAttempts.get(matchId) ?? 0) + 1;
|
||||||
|
this.knifeAttempts.set(matchId, attempt);
|
||||||
|
const again = attempt < KNIFE_RETRIES;
|
||||||
|
this.update(matchId, {
|
||||||
|
knife_at: null,
|
||||||
|
knife_phase: null,
|
||||||
|
message: again
|
||||||
|
? `Messerrunde wird erneut eingerichtet (Versuch ${attempt + 1} von ${KNIFE_RETRIES}): ${err.message}`
|
||||||
|
: `Messerrunde konnte nicht eingerichtet werden: ${err.message}`,
|
||||||
|
});
|
||||||
|
this.service.log(matchId, 'server', `Messerrunde nicht eingerichtet: ${err.message}`);
|
||||||
|
if (again) this.timers.push(setTimeout(() => this.maybeConfigure(matchId), this.knifeRetryDelayMs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.knifeAttempts.delete(matchId);
|
||||||
|
this.update(matchId, { knife_at: Date.now(), knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
|
||||||
|
this.service.log(matchId, 'server', 'Server für die Messerrunde bereit (Aufwärmphase)');
|
||||||
|
} finally {
|
||||||
|
this.busy.delete(matchId);
|
||||||
|
}
|
||||||
|
// Ist das Veto inzwischen durch, gleich das Match einrichten (der Aufruf dafür kam, während wir beschäftigt waren)
|
||||||
|
if (this.service.getMatch(matchId).status !== 'lobby') this.maybeConfigure(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Organisator: Messerrunde starten (Aufwärmphase beenden) oder wiederholen (zurück in die Aufwärmphase). */
|
||||||
|
async knifeAction(viewer, what) {
|
||||||
|
this.service.require(viewer, 'organizer');
|
||||||
|
const match = this.service.getMatch(viewer.matchId);
|
||||||
|
const row = this.row(viewer.matchId);
|
||||||
|
if (match.status !== 'lobby' || !row?.knife_at) throw new HttpError(409, 'Der Server ist noch nicht für die Messerrunde bereit', 'wrong_state');
|
||||||
|
const start = what === 'start';
|
||||||
|
try {
|
||||||
|
await this.adapter.sendCommands(JSON.parse(row.handle), start ? ['mp_warmup_pausetimer 0', 'mp_warmup_end'] : ['mp_warmup_pausetimer 1', 'mp_warmup_start']);
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||||
|
}
|
||||||
|
this.update(viewer.matchId, start
|
||||||
|
? { knife_phase: 'live', message: 'Messerrunde läuft' }
|
||||||
|
: { knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
|
||||||
|
this.service.log(viewer.matchId, 'server', start ? 'Messerrunde gestartet' : 'Messerrunde zurück in die Aufwärmphase');
|
||||||
|
}
|
||||||
|
|
||||||
async configure(matchId) {
|
async configure(matchId) {
|
||||||
const row = this.row(matchId);
|
const row = this.row(matchId);
|
||||||
if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server');
|
if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server');
|
||||||
@@ -444,16 +630,26 @@ export class ServerManager {
|
|||||||
});
|
});
|
||||||
this.simulator.stop(matchId);
|
this.simulator.stop(matchId);
|
||||||
this.service.resetLiveScore(matchId, seriesMap.number);
|
this.service.resetLiveScore(matchId, seriesMap.number);
|
||||||
|
// Lief vorher die Messerrunde auf diesem Server, deren Einstellungen zurücknehmen
|
||||||
|
if (row.knife_at) {
|
||||||
|
// Eine noch laufende Messerrunde zuerst anhalten: sonst endet sie (mp_maxrounds 1) mitten im Einrichten,
|
||||||
|
// und ihr Ergebnis würde als Ende der ersten Map gezählt
|
||||||
|
plan.beforeMapChange = ['mp_warmup_pausetimer 1', 'mp_warmup_start', ...plan.beforeMapChange];
|
||||||
|
plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange];
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.update(matchId, { status: 'config_failed', message: err.message });
|
const message = safeErrorMessage(err, [row.rcon_password, row.server_password, row.tv_password]);
|
||||||
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${err.message}`);
|
this.update(matchId, { status: 'config_failed', message });
|
||||||
|
this.service.log(matchId, 'server', `Konfiguration fehlgeschlagen: ${message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.update(matchId, {
|
this.update(matchId, {
|
||||||
status: 'configured',
|
status: 'configured',
|
||||||
configured_at: Date.now(),
|
configured_at: Date.now(),
|
||||||
|
knife_at: null,
|
||||||
|
knife_phase: null,
|
||||||
message: this.adapter.simulated
|
message: this.adapter.simulated
|
||||||
? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server`
|
? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server`
|
||||||
: `${mapName} geladen – Aufwärmphase läuft`,
|
: `${mapName} geladen – Aufwärmphase läuft`,
|
||||||
@@ -461,6 +657,7 @@ export class ServerManager {
|
|||||||
this.service.markCurrentMapLive(matchId);
|
this.service.markCurrentMapLive(matchId);
|
||||||
if (this.service.getMatch(matchId).status === 'server_setup') this.service.setStatus(matchId, 'live');
|
if (this.service.getMatch(matchId).status === 'server_setup') this.service.setStatus(matchId, 'live');
|
||||||
this.service.log(matchId, 'server', `Map ${seriesMap.number} (${mapName}) ist eingerichtet`);
|
this.service.log(matchId, 'server', `Map ${seriesMap.number} (${mapName}) ist eingerichtet`);
|
||||||
|
this.announceMapLoaded(matchId, seriesMap.number);
|
||||||
} finally {
|
} finally {
|
||||||
this.busy.delete(matchId);
|
this.busy.delete(matchId);
|
||||||
}
|
}
|
||||||
@@ -482,6 +679,12 @@ export class ServerManager {
|
|||||||
await this.provision(matchId);
|
await this.provision(matchId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.wantsKnife(match, row)) {
|
||||||
|
this.knifeAttempts.delete(matchId);
|
||||||
|
this.service.log(matchId, 'server', 'Organisator richtet die Messerrunde neu ein');
|
||||||
|
this.configureKnife(matchId).catch((err) => this.logger.error(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (row.status === 'config_failed' || (row.status === 'ready' && ['server_setup', 'live'].includes(match.status))) {
|
if (row.status === 'config_failed' || (row.status === 'ready' && ['server_setup', 'live'].includes(match.status))) {
|
||||||
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
|
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
|
||||||
this.configure(matchId).catch((err) => this.logger.error(err));
|
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||||
@@ -514,6 +717,9 @@ export class ServerManager {
|
|||||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||||
}
|
}
|
||||||
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
|
this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet');
|
||||||
|
this.stopWarmupRepeat(viewer.matchId);
|
||||||
|
const current = this.currentSeriesMap(viewer.matchId);
|
||||||
|
this.announce(viewer.matchId, matchStartLines({ teams: this.teamNames(viewer.matchId), ctTeam: current?.ct_team ?? null }));
|
||||||
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
|
// CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite
|
||||||
const fields = [];
|
const fields = [];
|
||||||
if (this.config.match.tvEnabled && row.tv_port) {
|
if (this.config.match.tvEnabled && row.tv_port) {
|
||||||
@@ -580,8 +786,32 @@ export class ServerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match beendet: Lief es bis zum Ende und ist CSTV mit Verzögerung an, bleibt der Server so lange stehen,
|
||||||
|
* bis die Zuschauer das Ende gesehen haben. CSTV läuft im selben Container und endet mit ihm.
|
||||||
|
*/
|
||||||
|
stopAfterClose(matchId) {
|
||||||
|
const match = this.service.getMatch(matchId);
|
||||||
|
const row = this.row(matchId);
|
||||||
|
const delay = this.config.match.tvEnabled ? Number(this.config.match.tvDelaySeconds) || 0 : 0;
|
||||||
|
if (match.status !== 'finished' || delay <= 0 || row?.status !== 'configured') {
|
||||||
|
this.stop(matchId, 'Match beendet').catch((err) => this.logger.error(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const waitMs = delay * 1000 + this.cstvGraceMs;
|
||||||
|
this.service.log(matchId, 'server', `Server läuft noch ${Math.round(waitMs / 1000)} s weiter, damit CSTV das Ende überträgt`);
|
||||||
|
clearTimeout(this.closeTimers.get(matchId));
|
||||||
|
this.closeTimers.set(matchId, setTimeout(() => {
|
||||||
|
this.closeTimers.delete(matchId);
|
||||||
|
this.stop(matchId, 'Match beendet').catch((err) => this.logger.error(err));
|
||||||
|
}, waitMs));
|
||||||
|
}
|
||||||
|
|
||||||
async stop(matchId, reason) {
|
async stop(matchId, reason) {
|
||||||
this.simulator.stop(matchId);
|
this.simulator.stop(matchId);
|
||||||
|
this.stopWarmupRepeat(matchId);
|
||||||
|
clearTimeout(this.closeTimers.get(matchId));
|
||||||
|
this.closeTimers.delete(matchId);
|
||||||
const row = this.row(matchId);
|
const row = this.row(matchId);
|
||||||
if (!row || row.status === 'stopped') return;
|
if (!row || row.status === 'stopped') return;
|
||||||
if (row.handle) await this.safeStop(row);
|
if (row.handle) await this.safeStop(row);
|
||||||
@@ -591,15 +821,29 @@ export class ServerManager {
|
|||||||
|
|
||||||
// ---------- Automatisches Herunterfahren ----------
|
// ---------- Automatisches Herunterfahren ----------
|
||||||
|
|
||||||
|
/** Lobbys, die länger als LOBBY_MAX_HOURS offen sind (Veto nie gestartet), samt Server löschen. */
|
||||||
|
async deleteStaleLobbies(now = Date.now()) {
|
||||||
|
const hours = this.config.lobbyMaxHours;
|
||||||
|
if (!hours) return 0;
|
||||||
|
const stale = this.db.prepare("SELECT id FROM matches WHERE status = 'lobby' AND created_at < ?").all(now - hours * 3_600_000);
|
||||||
|
for (const { id } of stale) {
|
||||||
|
if (this.busy.has(id)) continue;
|
||||||
|
this.logger.log?.(`[aufräumen] Lobby ${id} gelöscht (länger als ${hours} h offen)`);
|
||||||
|
await this.deleteMatch(id, `Lobby länger als ${hours} h offen`).catch((err) => this.logger.error(err));
|
||||||
|
}
|
||||||
|
return stale.length;
|
||||||
|
}
|
||||||
|
|
||||||
async reap(now = Date.now()) {
|
async reap(now = Date.now()) {
|
||||||
this.service.archiveClosed(now);
|
this.service.archiveClosed(now);
|
||||||
|
await this.deleteStaleLobbies(now);
|
||||||
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
|
const timeoutMs = this.config.idleTimeoutMinutes * 60_000;
|
||||||
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
|
const rows = this.db.prepare(`SELECT * FROM servers WHERE status IN (${ACTIVE.map(() => '?').join(',')})`).all(...ACTIVE);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (this.busy.has(row.match_id)) continue;
|
if (this.busy.has(row.match_id)) continue;
|
||||||
const match = this.service.getMatch(row.match_id);
|
const match = this.service.getMatch(row.match_id);
|
||||||
if (CLOSED_STATUSES.includes(match.status)) {
|
if (CLOSED_STATUSES.includes(match.status)) {
|
||||||
await this.stop(row.match_id, 'Match beendet');
|
if (!this.closeTimers.has(row.match_id)) await this.stop(row.match_id, 'Match beendet');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) {
|
if (row.started_at && now - row.started_at > this.config.maxLifetimeHours * 3_600_000) {
|
||||||
@@ -609,6 +853,8 @@ export class ServerManager {
|
|||||||
const webActive = this.service.presence(row.match_id).size > 0;
|
const webActive = this.service.presence(row.match_id).size > 0;
|
||||||
// Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder
|
// Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder
|
||||||
let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0);
|
let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0);
|
||||||
|
// Spieler auf dem Server während der Messerrunde: in Benutzung
|
||||||
|
if (row.knife_at && row.player_count > 0) continue;
|
||||||
if (row.status === 'configured' && row.player_count !== null) {
|
if (row.status === 'configured' && row.player_count !== null) {
|
||||||
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
|
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
|
||||||
if (row.player_count > 0) continue;
|
if (row.player_count > 0) continue;
|
||||||
|
|||||||
@@ -177,7 +177,11 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
|
|||||||
return s.status === 'live' && s.currentMapNumber === 2;
|
return s.status === 'live' && s.currentMapNumber === 2;
|
||||||
}, { label: 'Map 2' });
|
}, { label: 'Map 2' });
|
||||||
|
|
||||||
|
// Abschließen von Hand: der Server wartet auf das Ende der CSTV-Übertragung (hier kurz gehalten)
|
||||||
|
app.manager.config.match.tvDelaySeconds = 1;
|
||||||
|
app.manager.cstvGraceMs = 200;
|
||||||
assert.equal((await call('POST', `/matches/${id}/finish`, { token: org })).status, 200);
|
assert.equal((await call('POST', `/matches/${id}/finish`, { token: org })).status, 200);
|
||||||
|
assert.notEqual(app.service.serverRow(id).status, 'stopped', 'läuft für CSTV noch');
|
||||||
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' });
|
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' });
|
||||||
const list = (await call('GET', '/matches')).body.matches;
|
const list = (await call('GET', '/matches')).body.matches;
|
||||||
assert.equal(list.find((m) => m.id === id).group, 'closed');
|
assert.equal(list.find((m) => m.id === id).group, 'closed');
|
||||||
@@ -339,9 +343,15 @@ test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird
|
|||||||
await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' });
|
await waitFor(() => app.service.getMatch(id).current_map_number === 2 && app.service.serverRow(id).status === 'configured', { label: 'Map 2 automatisch' });
|
||||||
assert.equal(app.service.getMatch(id).status, 'live');
|
assert.equal(app.service.getMatch(id).status, 'live');
|
||||||
|
|
||||||
|
// CSTV-Verzögerung kurz halten: der Server soll erst danach gestoppt werden
|
||||||
|
app.manager.config.match.tvDelaySeconds = 1;
|
||||||
|
app.manager.cstvGraceMs = 300;
|
||||||
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
|
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
|
||||||
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt
|
// 2:0 im Best of 3 -> Match wird automatisch abgeschlossen, Map 3 nicht gespielt
|
||||||
await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' });
|
await waitFor(() => app.service.getMatch(id).status === 'finished', { label: 'Serie abgeschlossen' });
|
||||||
|
assert.equal(app.service.serverRow(id).status, 'configured', 'Server läuft für CSTV noch weiter');
|
||||||
|
await app.manager.reap();
|
||||||
|
assert.equal(app.service.serverRow(id).status, 'configured', 'auch der Aufräum-Lauf wartet auf CSTV');
|
||||||
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
|
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
|
||||||
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
|
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
|
||||||
@@ -537,6 +547,8 @@ test('Archiv: nur abgeschlossene Matches, eigene Liste, automatisch nach Frist',
|
|||||||
// Automatisch: abgebrochenes Match, älter als 3 Tage
|
// Automatisch: abgebrochenes Match, älter als 3 Tage
|
||||||
await call('POST', `/admin/matches/${old.id}/cancel`, { token });
|
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');
|
assert.equal(app.service.archiveClosed(Date.now() + 2 * 86_400_000), 0, 'nach 2 Tagen noch nicht');
|
||||||
|
// Hier geht es nur ums Archiv: das Löschen alter Lobbys (eigener Test) bleibt aus
|
||||||
|
app.manager.config.lobbyMaxHours = 0;
|
||||||
await app.manager.reap(Date.now() + 4 * 86_400_000);
|
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(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.ok(app.service.getMatch(done.id).archived_at > 0, 'zurückgeholtes Match wird nach Ablauf der Frist wieder archiviert');
|
||||||
@@ -607,3 +619,261 @@ test('Map-Pools im Admin: Auswahl gilt für neue Matches, Veto passt sich an, la
|
|||||||
await close();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('Konsole: nur für Admins, Ausgabe kommt live, unbekannte Server 404', 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 m = (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' } })).body;
|
||||||
|
await waitFor(() => app.service.serverRow(m.id)?.handle, { label: 'Server gestartet' });
|
||||||
|
const base = `http://127.0.0.1:${app.server.address().port}/api/admin/logs`;
|
||||||
|
|
||||||
|
assert.equal((await fetch(`${base}/match/${m.id}`)).status, 401);
|
||||||
|
assert.equal((await fetch(`${base}/match/gibtsnicht`, { headers: { authorization: `Bearer ${token}` } })).status, 404);
|
||||||
|
assert.equal((await fetch(`${base}/sonstwas/${m.id}`, { headers: { authorization: `Bearer ${token}` } })).status, 404);
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const res = await fetch(`${base}/match/${m.id}`, { headers: { authorization: `Bearer ${token}` }, signal: ctl.signal });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const { value } = await reader.read();
|
||||||
|
assert.match(new TextDecoder().decode(value), /Demo-Adapter/);
|
||||||
|
ctl.abort();
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, danach derselbe Server fürs Match', async () => {
|
||||||
|
const { app, call, close } = await setup();
|
||||||
|
try {
|
||||||
|
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'knife' } });
|
||||||
|
const { id, organizerToken: org } = created.body;
|
||||||
|
const codes = (await call('GET', `/matches/${id}`, { token: org })).body.organizer.codes;
|
||||||
|
const tokens = {};
|
||||||
|
for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) {
|
||||||
|
tokens[name] = (await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } })).body.playerToken;
|
||||||
|
}
|
||||||
|
// Vor dem Start: Aktionen gehen noch nicht
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 409);
|
||||||
|
|
||||||
|
await app.manager.poll();
|
||||||
|
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde eingerichtet' });
|
||||||
|
const player = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body;
|
||||||
|
assert.deepEqual(player.server.knife, { ready: true, phase: 'warmup', failed: false });
|
||||||
|
assert.equal(player.server.connect.password, app.manager.row(id).server_password, 'Spieler bekommen das Passwort des Matches');
|
||||||
|
const pub = (await call('GET', `/matches/${id}`)).body;
|
||||||
|
assert.equal(pub.server?.connect, undefined, 'öffentlich keine Verbindungsdaten');
|
||||||
|
|
||||||
|
// Nur der Organisator startet die Runde
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: tokens.a1 })).status, 403);
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 200);
|
||||||
|
assert.equal(app.manager.row(id).knife_phase, 'live');
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/server/knife-reset`, { token: org })).status, 200);
|
||||||
|
assert.equal(app.manager.row(id).knife_phase, 'warmup');
|
||||||
|
|
||||||
|
// Nach dem Veto wird derselbe Server fürs Match eingerichtet, die Messer-Einstellungen werden zurückgenommen
|
||||||
|
const handle = app.manager.row(id).handle;
|
||||||
|
const plans = [];
|
||||||
|
const configure = app.adapter.configure.bind(app.adapter);
|
||||||
|
app.adapter.configure = async (h, plan) => { plans.push(plan); return configure(h, plan); };
|
||||||
|
for (const t of Object.values(tokens)) await call('POST', `/matches/${id}/ready`, { token: t, body: { ready: true } });
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'A' } })).status, 200);
|
||||||
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
|
const capA = snap.teams.A.players.find((p) => p.isCaptain)?.nickname ?? 'a1';
|
||||||
|
const capB = snap.teams.B.players.find((p) => p.isCaptain)?.nickname ?? 'b1';
|
||||||
|
await playVeto(call, id, { A: tokens[capA], B: tokens[capB] });
|
||||||
|
// Das Ende der Messerrunde kommt während des Einrichtens an: darf nicht als Ende von Map 1 zählen
|
||||||
|
await waitFor(() => app.manager.row(id).status === 'configuring', { label: 'Map 1 wird eingerichtet' });
|
||||||
|
const firstMap = app.service.seriesMaps(id)[0].map;
|
||||||
|
const knifeEnd = ['Team "CT" triggered "SFUI_Notice_Target_Saved" (CT "1") (T "0")', 'World triggered "Round_End"',
|
||||||
|
`Game Over: scrimcomp2v2 ${firstMap} score 1:0 after 2 min`].join('\n');
|
||||||
|
const key = app.manager.row(id).config_key;
|
||||||
|
assert.equal((await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body: knifeEnd })).status, 200);
|
||||||
|
await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Match eingerichtet' });
|
||||||
|
const map1 = app.service.seriesMaps(id)[0];
|
||||||
|
assert.notEqual(map1.status, 'done', 'Messerrunde zählt nicht als Map-Ende');
|
||||||
|
assert.equal(map1.score_a, 0);
|
||||||
|
assert.equal(app.service.getMatch(id).current_map_number, 1);
|
||||||
|
assert.deepEqual(plans.at(-1).beforeMapChange.slice(0, 2), ['mp_warmup_pausetimer 1', 'mp_warmup_start'], 'laufende Messerrunde wird zuerst angehalten');
|
||||||
|
const row = app.manager.row(id);
|
||||||
|
assert.equal(row.handle, handle, 'derselbe Server');
|
||||||
|
assert.equal(row.knife_at, null);
|
||||||
|
assert.ok(plans.at(-1).afterMapChange.includes('mp_give_player_c4 1'));
|
||||||
|
assert.equal((await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.server.knife, undefined);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Messerrunde: Einrichten wird bei Fehlern von selbst wiederholt, danach kann der Organisator neu starten', async () => {
|
||||||
|
const { app, call, close } = await setup();
|
||||||
|
try {
|
||||||
|
app.manager.knifeRetryDelayMs = 0;
|
||||||
|
const configure = app.adapter.configure.bind(app.adapter);
|
||||||
|
let failures = 2;
|
||||||
|
app.adapter.configure = async (h, plan) => {
|
||||||
|
if (failures > 0) { failures -= 1; throw new Error('Map de_mirage wurde nicht rechtzeitig geladen'); }
|
||||||
|
return configure(h, plan);
|
||||||
|
};
|
||||||
|
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'knife' } });
|
||||||
|
const { id, organizerToken: org } = created.body;
|
||||||
|
await app.manager.poll();
|
||||||
|
// Zwei Fehlschläge, der dritte Versuch klappt ohne Zutun
|
||||||
|
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde nach Wiederholung eingerichtet' });
|
||||||
|
|
||||||
|
// Scheitern alle Versuche, bekommt der Organisator den Knopf
|
||||||
|
failures = 99;
|
||||||
|
app.manager.update(id, { knife_at: null, knife_phase: null });
|
||||||
|
app.manager.knifeAttempts.delete(id);
|
||||||
|
app.manager.maybeConfigure(id);
|
||||||
|
await waitFor(async () => (await call('GET', `/matches/${id}`, { token: org })).body.server.knife.failed, { label: 'als fehlgeschlagen gemeldet' });
|
||||||
|
failures = 0;
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: org })).status, 200);
|
||||||
|
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'von Hand neu eingerichtet' });
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Offene Lobbys werden nach LOBBY_MAX_HOURS samt Server gelöscht, laufende Matches nicht', async () => {
|
||||||
|
const { app, call, close } = await setup();
|
||||||
|
try {
|
||||||
|
const make = async () => (await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } })).body.id;
|
||||||
|
const old = await make();
|
||||||
|
const fresh = await make();
|
||||||
|
const { id: running, org } = await readyWingmanMatch(call, 'bo1');
|
||||||
|
assert.equal((await call('POST', `/matches/${running}/veto/start`, { token: org })).status, 200);
|
||||||
|
const ago = (h) => Date.now() - h * 3_600_000;
|
||||||
|
app.service.db.prepare('UPDATE matches SET created_at = ? WHERE id IN (?, ?)').run(ago(4), old, running);
|
||||||
|
app.service.db.prepare('UPDATE matches SET created_at = ? WHERE id = ?').run(ago(2), fresh);
|
||||||
|
|
||||||
|
await app.manager.reap();
|
||||||
|
const left = app.service.db.prepare('SELECT id FROM matches').all().map((r) => r.id);
|
||||||
|
assert.ok(!left.includes(old), 'alte Lobby gelöscht');
|
||||||
|
assert.ok(left.includes(fresh), 'Lobby unter 3 h bleibt');
|
||||||
|
assert.ok(left.includes(running), 'Match im Veto bleibt');
|
||||||
|
assert.equal(app.service.db.prepare('SELECT COUNT(*) AS n FROM servers WHERE match_id = ?').get(old).n, 0, 'Server-Datensatz weg');
|
||||||
|
assert.equal((await call('GET', `/matches/${old}`)).status, 404);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ingame-Chat: Hinweise nach dem Laden, beim Start und am Map-Ende', async () => {
|
||||||
|
const { app, call, close } = await setup();
|
||||||
|
try {
|
||||||
|
const said = [];
|
||||||
|
const send = app.adapter.sendCommands.bind(app.adapter);
|
||||||
|
app.adapter.sendCommands = async (h, commands) => {
|
||||||
|
said.push(...commands.filter((c) => c.startsWith('say ')).map((c) => c.slice(4).replace(/[-]/g, '').trim()));
|
||||||
|
return send(h, commands);
|
||||||
|
};
|
||||||
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
||||||
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
||||||
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
|
const cap = (t) => tokens[snap.teams[t].players.find((p) => p.isCaptain).nickname];
|
||||||
|
await playVeto(call, id, { A: cap('A'), B: cap('B') });
|
||||||
|
await app.manager.poll();
|
||||||
|
await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Map 1 eingerichtet' });
|
||||||
|
await waitFor(() => said.some((l) => l.startsWith('Map 1 von 3')), { label: 'Hinweis nach dem Laden' });
|
||||||
|
assert.ok(said.some((l) => / startet als CT, .* als T$/.test(l)), 'Seiten werden genannt');
|
||||||
|
assert.ok(said.includes('Serienstand: Alpha 0:0 Bravo'));
|
||||||
|
|
||||||
|
assert.equal((await call('POST', `/matches/${id}/server/warmup-end`, { token: org })).status, 200);
|
||||||
|
await waitFor(() => said.some((l) => l.startsWith('Match live!')), { label: 'Hinweis beim Start' });
|
||||||
|
|
||||||
|
const map = app.service.seriesMaps(id)[0].map;
|
||||||
|
const key = app.manager.row(id).config_key;
|
||||||
|
const body = ['Team playing "CT": Alpha', 'Team playing "TERRORIST": Bravo', 'World triggered "Match_Start" on "' + map + '"',
|
||||||
|
'Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")', 'World triggered "Round_End"',
|
||||||
|
`Game Over: scrimcomp2v2 ${map} score 1:0 after 1 min`].join('\n');
|
||||||
|
await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body });
|
||||||
|
await waitFor(() => said.some((l) => /^Map 1 \(.+\) geht an Alpha mit 1:0$/.test(l)), { label: 'Hinweis am Map-Ende' });
|
||||||
|
assert.ok(said.some((l) => l.startsWith('Nächste Map: ')));
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { chatText, COLOR, mapEndLines, mapLoadedLines, matchStartLines, sayCommands } from '../src/servers/chatAnnouncements.js';
|
||||||
|
|
||||||
|
const teams = { A: 'Nordlicht', B: 'Hafenkante' };
|
||||||
|
// Text ohne Farbcodes, zum Vergleichen des Inhalts
|
||||||
|
const plain = (lines) => lines.map((l) => l.replace(/[-]/g, ''));
|
||||||
|
|
||||||
|
test('Hinweise nach dem Laden: Map, Wahl, Seiten, Serienstand, Aufwärmphase', () => {
|
||||||
|
const lines = mapLoadedLines({
|
||||||
|
teams, total: 3, series: { A: 1, B: 0 },
|
||||||
|
map: { number: 2, mapName: 'Inferno', pickedBy: 'B', sideChosenBy: 'A', ctTeam: 'A' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(plain(lines), [
|
||||||
|
'Map 2 von 3: Inferno - gewählt von Hafenkante',
|
||||||
|
'Nordlicht hat die Seite gewählt: Nordlicht startet als CT, Hafenkante als T',
|
||||||
|
'Serienstand: Nordlicht 1:0 Hafenkante',
|
||||||
|
'Aufwärmphase - das Match startet, sobald der Organisator es freigibt',
|
||||||
|
]);
|
||||||
|
const bo1 = mapLoadedLines({ teams, total: 1, series: { A: 0, B: 0 }, map: { number: 1, mapName: 'Nuke', pickedBy: null, sideChosenBy: 'B', ctTeam: 'B' } });
|
||||||
|
assert.equal(plain(bo1)[0], 'Map: Nuke - Entscheidungsmap');
|
||||||
|
assert.ok(!bo1.some((l) => l.startsWith('Serienstand')), 'kein Serienstand im BO1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hinweise bei Match-Start und Map-Ende', () => {
|
||||||
|
assert.deepEqual(plain(matchStartLines({ teams, ctTeam: 'B' })), ['Match live! Hafenkante (CT) gegen Nordlicht (T) - viel Glück, have fun']);
|
||||||
|
const next = mapEndLines({
|
||||||
|
teams, total: 3, decided: null, nextMapName: 'Inferno', autoAdvanceSeconds: 30, series: { A: 1, B: 0 },
|
||||||
|
map: { number: 1, mapName: 'Mirage', winner: 'A', score: { A: 13, B: 9 } },
|
||||||
|
});
|
||||||
|
assert.deepEqual(plain(next), [
|
||||||
|
'Map 1 (Mirage) geht an Nordlicht mit 13:9',
|
||||||
|
'Serienstand: Nordlicht 1:0 Hafenkante',
|
||||||
|
'Nächste Map: Inferno, wird in 30 s geladen - bleibt auf dem Server',
|
||||||
|
]);
|
||||||
|
const won = mapEndLines({
|
||||||
|
teams, total: 3, decided: 'B', nextMapName: null, autoAdvanceSeconds: 30, series: { A: 0, B: 2 },
|
||||||
|
map: { number: 2, mapName: 'Nuke', winner: 'B', score: { A: 5, B: 13 } },
|
||||||
|
});
|
||||||
|
assert.equal(plain(won)[1], 'Hafenkante gewinnt die Serie 2:0 - danke fürs Spielen!');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Chattexte können keine weiteren Befehle anhängen', () => {
|
||||||
|
assert.equal(chatText('Team"; rcon_password x; say "'), 'Team rcon_password x say');
|
||||||
|
assert.deepEqual(sayCommands(['Hallo; quit', '', 'Stand 13:9']), ['say Hallo quit', 'say Stand 13:9']);
|
||||||
|
assert.ok(chatText('x'.repeat(300)).length <= 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Farben: CT blau, T gold, Sieger grün, Aufwärmphase gelb; Eingaben bringen keine eigenen Farben mit', () => {
|
||||||
|
const loaded = mapLoadedLines({ teams, total: 1, series: { A: 0, B: 0 }, map: { number: 1, mapName: 'Nuke', pickedBy: null, sideChosenBy: 'B', ctTeam: 'B' } });
|
||||||
|
const sides = loaded.find((l) => l.includes('startet als'));
|
||||||
|
assert.ok(sides.includes(`${COLOR.blue}Hafenkante${COLOR.white}`), 'CT-Team blau');
|
||||||
|
assert.ok(sides.includes(`${COLOR.gold}Nordlicht${COLOR.white}`), 'T-Team gold');
|
||||||
|
assert.ok(loaded.at(-1).startsWith(COLOR.yellow), 'Aufwärmphase gelb');
|
||||||
|
assert.ok(matchStartLines({ teams, ctTeam: 'A' })[0].startsWith(`${COLOR.green}Match live!`));
|
||||||
|
const end = mapEndLines({ teams, total: 1, decided: null, nextMapName: null, autoAdvanceSeconds: 30, series: { A: 1, B: 0 }, map: { number: 1, mapName: 'Nuke', winner: 'A', score: { A: 13, B: 2 } } });
|
||||||
|
assert.ok(end[0].includes(`${COLOR.green}Nordlicht${COLOR.white}`), 'Sieger grün');
|
||||||
|
|
||||||
|
// Farbcodes im Teamnamen werden entfernt, die eigenen Farben bleiben
|
||||||
|
const evil = matchStartLines({ teams: { A: `BöseRot`, B: 'Gut' }, ctTeam: 'A' })[0];
|
||||||
|
assert.ok(evil.includes(`${COLOR.blue}BöseRot${COLOR.white}`));
|
||||||
|
const [cmd] = sayCommands([`${COLOR.yellow}gelb ${COLOR.blue}blau"; quit`]);
|
||||||
|
assert.equal(cmd, `say ${COLOR.yellow}gelb ${COLOR.blue}blau quit`, 'nur erlaubte Codes, kein Befehl anhängbar, Farben am Anfang bleiben');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mit CSTV-Verzögerung: Mapwechsel nach der Übertragung, Hinweis zum Abschalten am Serienende', () => {
|
||||||
|
const p = (lines) => lines.map((l) => l.replace(/[\x01-\x10]/g, ''));
|
||||||
|
const base = { teams, total: 3, autoAdvanceSeconds: 30, tvDelaySeconds: 90, map: { number: 1, mapName: 'Mirage', winner: 'A', score: { A: 13, B: 9 } } };
|
||||||
|
const next = p(mapEndLines({ ...base, decided: null, nextMapName: 'Inferno', series: { A: 1, B: 0 } }));
|
||||||
|
assert.equal(next.at(-1), 'Nächste Map: Inferno, wird in etwa 90 s geladen (nach der CSTV-Übertragung) - bleibt auf dem Server');
|
||||||
|
const end = p(mapEndLines({ ...base, decided: 'A', nextMapName: null, series: { A: 2, B: 0 }, shutdownSeconds: 135 }));
|
||||||
|
assert.equal(end.at(-1), 'Der Server bleibt noch etwa 2 Minuten für die CSTV-Übertragung online und schaltet sich dann ab.');
|
||||||
|
// Ohne CSTV-Verzögerung kein Hinweis zum Abschalten
|
||||||
|
const noTv = p(mapEndLines({ ...base, tvDelaySeconds: 0, decided: 'A', nextMapName: null, series: { A: 2, B: 0 } }));
|
||||||
|
assert.ok(!noTv.some((l) => l.includes('CSTV')));
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ test('Ohne GSLT startet der Container im LAN-Modus, mit GSLT mit Token', async (
|
|||||||
assert.equal(env.SRCDS_TOKEN, undefined);
|
assert.equal(env.SRCDS_TOKEN, undefined);
|
||||||
assert.equal(env.CS2_GAMEMODE, '2');
|
assert.equal(env.CS2_GAMEMODE, '2');
|
||||||
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
|
assert.equal(env.CS2_MAXPLAYERS, '6', '4 Spieler + CSTV + Reserve');
|
||||||
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files', 'Rundensicherung für Rollback');
|
assert.equal(env.CS2_ADDITIONAL_ARGS, '-allow_round_backup_files +sv_deltaticks_enforce 0', 'Rundensicherung für Rollback, keine Trennung wegen Delta-Ticks');
|
||||||
assert.equal(env.CS2_PORT, '27100');
|
assert.equal(env.CS2_PORT, '27100');
|
||||||
assert.equal(env.TV_PORT, '27200');
|
assert.equal(env.TV_PORT, '27200');
|
||||||
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
assert.ok(runArgs.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
||||||
@@ -97,3 +97,13 @@ test('Warteschlange gibt frei, sobald der Server per RCON antwortet – auch ohn
|
|||||||
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
|
assert.equal(d.calls.filter((c) => c[0] === 'run').length, 2);
|
||||||
reachable.add(r2.handle.container);
|
reachable.add(r2.handle.container);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('TV_DELAY geht an das Image, sonst setzt es CSTV nach jedem Mapwechsel auf 0 Sekunden', async () => {
|
||||||
|
const d = fakeDocker();
|
||||||
|
const a = new DockerServerAdapter(options, { getUsedPorts: () => new Set(), runDocker: d.run });
|
||||||
|
a.isReachable = async () => false;
|
||||||
|
await a.launch({ matchId: 'tv1', mode: 'competitive', secrets, tvDelaySeconds: 90 });
|
||||||
|
const run = d.calls.find((c) => c.args?.[0] === 'run' || c[0] === 'run');
|
||||||
|
const env = envOf(run.args ?? run);
|
||||||
|
assert.equal(env.TV_DELAY, '90');
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { formatDockerEvent } from '../src/servers/dockerAdapter.js';
|
||||||
|
|
||||||
|
const ev = (o) => JSON.stringify({ time: 1793549403, ...o });
|
||||||
|
|
||||||
|
test('Docker-Ereignisse werden lesbar, App-Container markiert', () => {
|
||||||
|
const start = formatDockerEvent(ev({ Type: 'container', Action: 'start', Actor: { ID: 'abc', Attributes: { name: 'cs2mv-knife1', image: 'joedwards32/cs2:latest', 'cs2-mapveto': '1' } } }));
|
||||||
|
assert.match(start, /Container cs2mv-knife1 \[Mapveto\] gestartet \(joedwards32\/cs2:latest\)$/);
|
||||||
|
const die = formatDockerEvent(ev({ Type: 'container', Action: 'die', Actor: { ID: 'abc', Attributes: { name: 'gitea', exitCode: '137' } } }));
|
||||||
|
assert.match(die, /Container gitea beendet \(Exit-Code 137\)$/);
|
||||||
|
assert.match(formatDockerEvent(ev({ Type: 'image', Action: 'pull', Actor: { ID: 'joedwards32/cs2:latest', Attributes: { name: 'joedwards32/cs2' } } })), /Image joedwards32\/cs2 geladen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Healthchecks, Mounts und kaputte Zeilen fallen weg', () => {
|
||||||
|
assert.equal(formatDockerEvent(ev({ Type: 'container', Action: 'exec_start: /bin/sh -c mysql --password=geheim', Actor: { Attributes: {} } })), null);
|
||||||
|
assert.equal(formatDockerEvent(ev({ Type: 'volume', Action: 'mount', Actor: { Attributes: {} } })), null);
|
||||||
|
assert.equal(formatDockerEvent('kein json'), null);
|
||||||
|
});
|
||||||
@@ -107,3 +107,29 @@ test('Alter Handle ohne passenden Host-Namen wird über die Adresse dem richtige
|
|||||||
assert.equal(p.adapterFor({ hostName: 'default', rconHost: '10.0.0.2' }).options.name, 'b');
|
assert.equal(p.adapterFor({ hostName: 'default', rconHost: '10.0.0.2' }).options.name, 'b');
|
||||||
assert.throws(() => p.adapterFor({ hostName: 'weg', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
|
assert.throws(() => p.adapterFor({ hostName: 'weg', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Neue Server meiden Hosts, auf denen gerade ein Server hochfährt (SteamCMD)', async () => {
|
||||||
|
const starting = { '10.0.0.2': 1 };
|
||||||
|
const p = new DockerHostPool(options([hostA, hostB]), {
|
||||||
|
getUsedPorts: () => new Set(),
|
||||||
|
getStarting: (publicHost, exclude) => (exclude === 'self' ? 0 : starting[publicHost] ?? 0),
|
||||||
|
runDocker: fakeDocker().run,
|
||||||
|
});
|
||||||
|
// b hat mehr Plätze, aber dort lädt gerade ein Container: a gewinnt
|
||||||
|
assert.equal(p.pickHost().name, 'a');
|
||||||
|
// Der hochfahrende Server selbst zählt nicht gegen seinen eigenen Neustart
|
||||||
|
assert.equal(p.pickHost('self').name, 'b');
|
||||||
|
// Warteschlange im Prozess zählt genauso
|
||||||
|
starting['10.0.0.2'] = 0;
|
||||||
|
p.adapters.get('b').pendingStarts = 1;
|
||||||
|
assert.equal(p.pickHost().name, 'a');
|
||||||
|
// Starts zählen hoch und nach dem Update-Check wieder herunter
|
||||||
|
const a = p.adapters.get('a');
|
||||||
|
let done;
|
||||||
|
a.waitForUpdateCheck = () => new Promise((r) => { done = r; });
|
||||||
|
await a.start({ matchId: 'm1', mode: 'competitive', secrets });
|
||||||
|
assert.equal(a.pendingStarts, 1);
|
||||||
|
done();
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(a.pendingStarts, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DockerHostPool, GAMEFILES_UPDATE_SCRIPT, parseUpdateOutput } from '../src/servers/dockerAdapter.js';
|
||||||
|
|
||||||
|
const options = (runDocker) => new DockerHostPool({
|
||||||
|
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9', portRangeStart: 27100, portRangeEnd: 27139,
|
||||||
|
volume: 'cs2-mapveto-gamefiles', startTimeoutMinutes: 1, lanPorts: [],
|
||||||
|
hosts: [{ name: 'a', publicHost: '10.0.0.1' }, { name: 'b', publicHost: '10.0.0.2', volume: 'andere-dateien' }],
|
||||||
|
}, { runDocker });
|
||||||
|
|
||||||
|
test('Ausgabe von SteamCMD: Fortschritt und Build-Nummern', () => {
|
||||||
|
const info = parseUpdateOutput('BUILD_BEFORE=100\n Update state (0x61) downloading, progress: 42.50 (1 / 2)\n');
|
||||||
|
assert.equal(info.buildBefore, '100');
|
||||||
|
assert.equal(info.progress, 42.5);
|
||||||
|
parseUpdateOutput('Success! App \'730\' fully installed.\nBUILD_AFTER=105\n', info);
|
||||||
|
assert.equal(info.buildAfter, '105');
|
||||||
|
assert.match(GAMEFILES_UPDATE_SCRIPT, /\+app_update 730 -beta public \+quit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Spieldateien-Update: eigener Container je Host, Hosts mit Servern ausgelassen', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const pool = options(async (args, host) => {
|
||||||
|
calls.push({ args, host });
|
||||||
|
return 'BUILD_BEFORE=100\nBUILD_AFTER=105\n';
|
||||||
|
});
|
||||||
|
const res = await pool.updateGameFiles({ skip: new Map([['b', 'ausgelassen: 1 laufender Server']]) });
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].host, 'a');
|
||||||
|
const args = calls[0].args;
|
||||||
|
assert.deepEqual(args.slice(0, 2), ['run', '--rm']);
|
||||||
|
assert.ok(args.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
|
||||||
|
assert.equal(args[args.indexOf('--entrypoint') + 1], 'bash');
|
||||||
|
assert.ok(!args.includes('cs2-mapveto=1'), 'Update-Container zählen nicht als Gameserver');
|
||||||
|
assert.deepEqual(res.results.map((r) => [r.name, r.state]), [['a', 'ok'], ['b', 'skipped']]);
|
||||||
|
assert.match(res.results[0].message, /Build 105 \(vorher 100\)/);
|
||||||
|
assert.equal(res.running, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Bereits aktuell und Fehler werden gemeldet', async () => {
|
||||||
|
const same = options(async () => 'BUILD_BEFORE=105\nBUILD_AFTER=105\n');
|
||||||
|
assert.match((await same.updateGameFiles()).results[0].message, /bereits aktuell \(Build 105\)/);
|
||||||
|
const broken = options(async () => { throw new Error('SteamCMD failed (rc 8)'); });
|
||||||
|
const r = (await broken.updateGameFiles()).results;
|
||||||
|
assert.deepEqual(r.map((x) => x.state), ['failed', 'failed']);
|
||||||
|
assert.match(r[0].message, /rc 8/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Während des Updates startet auf dem Host kein neuer Server', () => {
|
||||||
|
const pool = options(async () => '');
|
||||||
|
pool.files = { running: true, results: [{ name: 'a', state: 'running' }, { name: 'b', state: 'ok' }] };
|
||||||
|
assert.equal(pool.pickHost().name, 'b');
|
||||||
|
pool.files.results[1].state = 'running';
|
||||||
|
assert.throws(() => pool.pickHost(), /Spieldateien/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unvollständige Installation oder SteamCMD-Fehler gelten als fehlgeschlagen, auch mit Exit-Code 0', async () => {
|
||||||
|
const partial = options(async () => 'BUILD_BEFORE=0\nBUILD_AFTER=0\nSTATE_AFTER=1026\n');
|
||||||
|
const [a] = (await partial.updateGameFiles()).results;
|
||||||
|
assert.equal(a.state, 'failed');
|
||||||
|
assert.match(a.message, /StateFlags 1026/);
|
||||||
|
const err = options(async () => "Error! App '730' state is 0x202 after update job.\nBUILD_BEFORE=1\nBUILD_AFTER=1\nSTATE_AFTER=4\n");
|
||||||
|
assert.match((await err.updateGameFiles()).results[0].message, /Error! App '730'/);
|
||||||
|
const fine = options(async () => 'BUILD_BEFORE=1\nBUILD_AFTER=2\nSTATE_AFTER=4\n');
|
||||||
|
assert.equal((await fine.updateGameFiles()).results[0].state, 'ok');
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { io as connect } from 'socket.io-client';
|
||||||
|
import { loadConfig } from '../src/config.js';
|
||||||
|
import { createApp } from '../src/app.js';
|
||||||
|
import { DemoServerAdapter } from '../src/servers/demoAdapter.js';
|
||||||
|
import { applyLogLines, initialLiveState } from '../src/liveScore.js';
|
||||||
|
import { RconClient } from '../src/servers/rcon.js';
|
||||||
|
import { createRateLimiter, safeErrorMessage } from '../src/security.js';
|
||||||
|
|
||||||
|
// Absicherungen aus der Prüfung: jede Stelle mit dem Fehler, der vorher möglich war.
|
||||||
|
|
||||||
|
const quiet = { log() {}, warn() {}, error() {} };
|
||||||
|
|
||||||
|
async function start(overrides = {}, adapter) {
|
||||||
|
const config = loadConfig({
|
||||||
|
databaseFile: ':memory:', serverAdapter: 'demo', createMatchPassword: '',
|
||||||
|
demo: { startSeconds: 0, failFirstConfigure: false }, ...overrides,
|
||||||
|
});
|
||||||
|
const app = createApp(config, { logger: quiet, adapter });
|
||||||
|
await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = app.server.address().port;
|
||||||
|
const call = async (method, path, { token, body } = {}) => {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/api${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
};
|
||||||
|
return { app, port, call, close: () => app.close() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMatch = (call) => call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } });
|
||||||
|
|
||||||
|
test('Kaputte Socket-Nachrichten beenden das Backend nicht', async () => {
|
||||||
|
const { port, call, close } = await start();
|
||||||
|
const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false });
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => { socket.on('connect', resolve); socket.on('connect_error', reject); });
|
||||||
|
for (const payload of [null, 5, 'x', [], { matchId: { $ne: 1 } }]) {
|
||||||
|
socket.emit('stream:subscribe', payload);
|
||||||
|
socket.emit('match:subscribe', payload);
|
||||||
|
socket.emit('overview:subscribe', payload);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
assert.equal((await call('GET', '/config')).status, 200, 'Backend antwortet noch');
|
||||||
|
} finally {
|
||||||
|
socket.close();
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zu viele Socket-Abfragen werden gebremst', async () => {
|
||||||
|
const { port, close } = await start();
|
||||||
|
const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false });
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => socket.on('connect', resolve));
|
||||||
|
let limited = 0;
|
||||||
|
socket.on('rate_limited', () => { limited += 1; });
|
||||||
|
for (let i = 0; i < 60; i++) socket.emit('overview:subscribe');
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
assert.ok(limited > 0, 'ab einer Grenze werden Abfragen abgewiesen');
|
||||||
|
} finally {
|
||||||
|
socket.close();
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fehlgeschlagener Serverstart veröffentlicht keine Passwörter', async () => {
|
||||||
|
class Failing extends DemoServerAdapter {
|
||||||
|
async start({ secrets }) {
|
||||||
|
const err = new Error(`Command failed: docker run -e CS2_RCONPW=${secrets.rconPassword} -e CS2_PW=${secrets.serverPassword} joedwards32/cs2`);
|
||||||
|
err.stderr = '';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { app, call, close } = await start({}, new Failing({ startSeconds: 0 }));
|
||||||
|
try {
|
||||||
|
const { id } = (await newMatch(call)).body;
|
||||||
|
await app.manager.idle();
|
||||||
|
const row = app.manager.row(id);
|
||||||
|
assert.equal(row.status, 'start_failed');
|
||||||
|
const pub = JSON.stringify((await call('GET', `/matches/${id}`)).body);
|
||||||
|
for (const secret of [row.rcon_password, row.server_password]) {
|
||||||
|
assert.ok(!pub.includes(secret), 'Passwort nicht in der öffentlichen Ansicht');
|
||||||
|
assert.ok(!row.message.includes(secret), 'Passwort nicht in der Meldung');
|
||||||
|
}
|
||||||
|
assert.ok(!JSON.stringify(app.service.db.prepare('SELECT message FROM events WHERE match_id = ?').all(id)).includes(row.rcon_password));
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('safeErrorMessage schwärzt Befehlszeile, Variablen und bekannte Geheimnisse', () => {
|
||||||
|
const msg = safeErrorMessage({ message: 'Command failed: docker run -e CS2_RCONPW=abc123\nError: port is already allocated', stderr: '' });
|
||||||
|
assert.equal(msg, 'Error: port is already allocated');
|
||||||
|
assert.equal(safeErrorMessage({ stderr: 'bad TV_PW=geheim1 SRCDS_TOKEN=XYZ999' }), 'bad TV_PW=*** SRCDS_TOKEN=***');
|
||||||
|
assert.equal(safeErrorMessage(new Error('Server sagt Passwort1234 falsch'), ['Passwort1234']), 'Server sagt *** falsch');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Chat-Nachrichten können keine Map beenden oder starten', () => {
|
||||||
|
const state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true });
|
||||||
|
const ts = '11/01/2026 - 20:00:00.000 - ';
|
||||||
|
const lines = [
|
||||||
|
`${ts}"Troll<3><[U:1:1]><CT>" say "World triggered "Match_Start" on "de_mirage""`,
|
||||||
|
`${ts}"Troll<3><[U:1:1]><CT>" say "Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")"`,
|
||||||
|
`${ts}"Troll<3><[U:1:1]><CT>" say "Game Over: competitive mg_active de_mirage score 13:0 after 3 min"`,
|
||||||
|
`${ts}"Game Over: x de_mirage score 13:0 after 3 min<4><[U:1:2]><T>" say "hi"`,
|
||||||
|
];
|
||||||
|
const { state: after, events } = applyLogLines(state, lines, { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 });
|
||||||
|
assert.deepEqual(events, []);
|
||||||
|
assert.notEqual(after.status, 'finished');
|
||||||
|
assert.deepEqual(after.score, { A: 0, B: 0 });
|
||||||
|
// Die echte Systemzeile wirkt weiterhin
|
||||||
|
const real = applyLogLines(after, [`${ts}Game Over: competitive mg_active de_mirage score 0:0 after 3 min`], { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 });
|
||||||
|
assert.equal(real.events[0]?.type, 'map_end');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stopp oder Löschen während des Starts baut den verspäteten Container wieder ab', async () => {
|
||||||
|
let release;
|
||||||
|
const stopped = [];
|
||||||
|
class Slow extends DemoServerAdapter {
|
||||||
|
async start(args) {
|
||||||
|
await new Promise((r) => { release = r; });
|
||||||
|
return super.start(args);
|
||||||
|
}
|
||||||
|
async stop(handle) {
|
||||||
|
stopped.push(handle.matchId);
|
||||||
|
return super.stop(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { app, call, close } = await start({}, new Slow({ startSeconds: 0 }));
|
||||||
|
try {
|
||||||
|
const { id } = (await newMatch(call)).body;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
await app.manager.stop(id, 'Test');
|
||||||
|
release();
|
||||||
|
await app.manager.idle();
|
||||||
|
assert.deepEqual(stopped, [id], 'verspäteter Container wurde gestoppt');
|
||||||
|
assert.equal(app.manager.row(id).status, 'stopped');
|
||||||
|
assert.equal(app.manager.row(id).handle, null);
|
||||||
|
|
||||||
|
// Löschen während des Starts
|
||||||
|
const second = (await newMatch(call)).body.id;
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const deleting = app.manager.deleteMatch(second, 'Test');
|
||||||
|
await deleting;
|
||||||
|
release();
|
||||||
|
await app.manager.idle();
|
||||||
|
assert.ok(stopped.includes(second));
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Kleiner RCON-Server, der nach der Anmeldung tut, was der Test vorgibt. */
|
||||||
|
async function fakeRcon(onCommand) {
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
socket.on('data', (buf) => {
|
||||||
|
const id = buf.readInt32LE(4);
|
||||||
|
const type = buf.readInt32LE(8);
|
||||||
|
if (type === 3) {
|
||||||
|
const ok = Buffer.alloc(14);
|
||||||
|
ok.writeInt32LE(10, 0); ok.writeInt32LE(id, 4); ok.writeInt32LE(2, 8);
|
||||||
|
socket.write(ok);
|
||||||
|
} else onCommand(socket, id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||||
|
return { port: server.address().port, close: () => server.close() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('RCON: kaputte Pakete trennen nur die Verbindung, fehlende Antworten sind ein Fehler', async () => {
|
||||||
|
const bad = await fakeRcon((socket) => {
|
||||||
|
const junk = Buffer.alloc(8);
|
||||||
|
junk.writeInt32LE(5, 0); // Paketgröße unter dem Minimum
|
||||||
|
socket.write(junk);
|
||||||
|
});
|
||||||
|
const silent = await fakeRcon(() => {});
|
||||||
|
try {
|
||||||
|
const a = await new RconClient({ host: '127.0.0.1', port: bad.port, password: 'x', timeoutMs: 1000 }).connect();
|
||||||
|
await assert.rejects(a.command('status'), /geschlossen|keine Antwort/);
|
||||||
|
a.close();
|
||||||
|
const b = await new RconClient({ host: '127.0.0.1', port: silent.port, password: 'x', timeoutMs: 300 }).connect();
|
||||||
|
await assert.rejects(b.command('mp_warmup_end'), /keine Antwort auf "mp_warmup_end"/);
|
||||||
|
b.close();
|
||||||
|
} finally {
|
||||||
|
bad.close();
|
||||||
|
silent.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Organisatoren bekommen nur einen Stream-Schlüssel für ihr Match, nicht den globalen', async () => {
|
||||||
|
const { call, close } = await start({ streamKey: 'GlobalerSchluessel42' });
|
||||||
|
try {
|
||||||
|
const one = (await newMatch(call)).body;
|
||||||
|
const two = (await newMatch(call)).body;
|
||||||
|
const view = (await call('GET', `/matches/${one.id}`, { token: one.organizerToken })).body;
|
||||||
|
const link = view.organizer.links.overview;
|
||||||
|
assert.ok(!link.includes('GlobalerSchluessel42'), 'globaler Schlüssel nicht im Link');
|
||||||
|
const key = new URL(link).searchParams.get('key');
|
||||||
|
const scoped = (await call('GET', `/stream/overview?key=${encodeURIComponent(key)}`)).body;
|
||||||
|
const ids = [...scoped.active, ...scoped.upcoming, ...scoped.results].map((m) => m.id);
|
||||||
|
assert.deepEqual(ids, [one.id], 'nur das eigene Match');
|
||||||
|
assert.ok(!ids.includes(two.id));
|
||||||
|
// Gefälschter Schlüssel für ein anderes Match geht nicht
|
||||||
|
assert.equal((await call('GET', `/stream/overview?key=${encodeURIComponent(`${two.id}.${key.split('.').pop()}`)}`)).status, 403);
|
||||||
|
// Der globale Schlüssel zeigt weiter alles
|
||||||
|
const all = (await call('GET', '/stream/overview?key=GlobalerSchluessel42')).body;
|
||||||
|
assert.equal(all.upcoming.length, 2);
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Rate-Limiter hält seinen Speicher begrenzt', () => {
|
||||||
|
const check = createRateLimiter({ windowMs: 60_000, max: 5, maxKeys: 100 });
|
||||||
|
for (let i = 0; i < 1000; i++) check(`ip-${i}`);
|
||||||
|
assert.ok(check.size() <= 100);
|
||||||
|
for (let i = 0; i < 5; i++) assert.equal(check('x'), true);
|
||||||
|
assert.equal(check('x'), false);
|
||||||
|
});
|
||||||
@@ -78,6 +78,9 @@ test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht',
|
|||||||
};
|
};
|
||||||
const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange;
|
const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange;
|
||||||
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
|
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
|
||||||
|
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('mp_endmatch_votenextmap 0'), 'keine Map-Abstimmung nach dem Match');
|
||||||
|
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('sv_deltaticks_enforce 0'), 'keine Trennung wegen Delta-Ticks');
|
||||||
|
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.some((c) => /^tv_delay \d+$/.test(c)), 'tv_delay nach dem Laden der Map');
|
||||||
assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
|
assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
|
||||||
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
|
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
|
||||||
assert.ok(!bad.some((c) => c.startsWith('logaddress')));
|
assert.ok(!bad.some((c) => c.startsWith('logaddress')));
|
||||||
|
|||||||
Reference in new issue
Block a user