Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be474ee143 | ||
|
|
301da179ed | ||
|
|
7bceceb31d | ||
|
|
eee4ceb315 | ||
|
|
c33185db3e | ||
|
|
1da08b256e | ||
|
|
1d627c212a | ||
|
|
e7f595176a | ||
|
|
bfe315d70c | ||
|
|
d2a811f177 | ||
|
|
c69d5548e5 | ||
|
|
af4f036515 | ||
|
|
91213d354f | ||
|
|
4fa4db3640 | ||
|
|
a98a34d769 | ||
|
|
3bfe087b7d | ||
|
|
43ecb8a1ad | ||
|
|
46d1954a6d | ||
|
|
30ec5359b1 | ||
|
|
5527c2ce58 | ||
|
|
1b7c13b592 | ||
|
|
e3a1ed4a63 | ||
|
|
84dad0993c | ||
|
|
3a109883b3 | ||
|
|
85041b2bff | ||
|
|
ea2207899f | ||
|
|
263c7e7b7c | ||
|
|
306b3aa7e5 | ||
|
|
34e5fc7d0e | ||
|
|
8be8b3d879 | ||
|
|
3a74e16bb8 | ||
|
|
f1fc1642ab | ||
|
|
db4a6bedf7 | ||
|
|
7be0d85ab5 | ||
|
|
e304556346 | ||
|
|
bf438511d6 | ||
|
|
f7520a6ca7 | ||
|
|
f08297dd0d |
No files matched your search
@@ -52,9 +52,6 @@ CS2_PORT_RANGE_END=27115
|
|||||||
# Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt
|
# Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt
|
||||||
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
|
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
|
||||||
CS2_DOCKER_EXTRA_ARGS=
|
CS2_DOCKER_EXTRA_ARGS=
|
||||||
# Optional: mehrere Docker-Hosts als JSON-Liste (eine Zeile). Leer = nur CS2_PUBLIC_HOST/DOCKER_HOST.
|
|
||||||
# Einrichtung pro Host: bash deploy/docker-host-einrichten.sh <ssh-ziel>
|
|
||||||
# CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
|
|
||||||
CS2_START_TIMEOUT_MINUTES=20
|
CS2_START_TIMEOUT_MINUTES=20
|
||||||
DOCKER_BIN=docker
|
DOCKER_BIN=docker
|
||||||
|
|
||||||
|
|||||||
@@ -236,24 +236,6 @@ Voraussetzungen:
|
|||||||
|
|
||||||
Container heißen `cs2mv-<matchId>` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`.
|
Container heißen `cs2mv-<matchId>` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`.
|
||||||
|
|
||||||
### Mehrere Docker-Hosts
|
|
||||||
|
|
||||||
Mit `CS2_DOCKER_HOSTS` verteilt die Anwendung Matches auf mehrere Docker-Hosts. Jedes neue Match läuft auf dem Host mit den meisten freien Ports. Der Host bleibt im Match gespeichert, Status, Konfiguration und Stopp gehen immer an denselben Host.
|
|
||||||
|
|
||||||
1. Jeden Host einrichten (SSH-Schlüssel, Docker, Volume, Image):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash deploy/docker-host-einrichten.sh cs2@192.168.178.111
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Die Hosts in der `.env` eintragen, als JSON-Liste in einer Zeile:
|
|
||||||
|
|
||||||
```
|
|
||||||
CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139},{"name":"host2","dockerHost":"ssh://cs2@192.168.178.112","publicHost":"192.168.178.112","portStart":27100,"portEnd":27139}]
|
|
||||||
```
|
|
||||||
|
|
||||||
Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen auf jedem Host frei sein.
|
|
||||||
|
|
||||||
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
|
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
|
||||||
|
|
||||||
Für selbst betriebene, dauerhaft laufende CS2-Server:
|
Für selbst betriebene, dauerhaft laufende CS2-Server:
|
||||||
@@ -329,7 +311,7 @@ Beim Konfigurieren setzt CS2 Mapveto auf dem Server `log on` und `logaddress_add
|
|||||||
- Stream-Seite
|
- Stream-Seite
|
||||||
- **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell.
|
- **Nach dem Spielende:** Ist die Serie noch offen, lädt CS2 Mapveto nach `SERIES_AUTO_ADVANCE_SECONDS` (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit `-1` geht beides nur manuell.
|
||||||
- **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus.
|
- **Voraussetzung:** Der CS2-Server muss `LOG_RECEIVER_URL` erreichen, im LAN z. B. `http://192.168.178.31:3000`. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit `LOG_DEBUG=true` gibt das Backend alle empfangenen Zeilen aus.
|
||||||
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), anonymisiert in `server/test/fixtures/wingman-anonym.log`.
|
- **Geprüft:** mit echten Logzeilen eines CS2-Servers (Wingman), siehe `server/test/fixtures/echte-logs-wingman.log`.
|
||||||
|
|
||||||
Zwei Eigenheiten von CS2 berücksichtigt der Parser:
|
Zwei Eigenheiten von CS2 berücksichtigt der Parser:
|
||||||
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
|
- CS2 sendet `Match_Start` schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
|
||||||
@@ -375,45 +357,6 @@ Jede Meldung enthält einen Link auf die Match-Übersicht (`/stream/match/<id>`)
|
|||||||
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.
|
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.
|
||||||
|
|
||||||
Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung.
|
Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung.
|
||||||
### Hinter einem Reverse-Proxy
|
|
||||||
|
|
||||||
Die Webseite lässt sich hinter einen Reverse-Proxy wie nginx, Caddy oder Traefik stellen, etwa um sie per HTTPS unter einer eigenen Adresse anzubieten.
|
|
||||||
|
|
||||||
- **Eigene (Sub-)Domain, kein Unterpfad.** Die Anwendung nutzt absolute Pfade (`/api`, `/socket.io`, `/stream`, `/admin`). Unter einem Unterpfad wie `example.de/veto/` funktioniert sie nicht. Nimm eine eigene Subdomain, z. B. `veto.example.de`.
|
|
||||||
- **WebSockets durchreichen.** Die Live-Updates laufen über Socket.IO. Der Proxy muss den `Upgrade`-Header weitergeben, sonst bleibt die Seite bei „Verbinde mit Lobby“ hängen.
|
|
||||||
- **`.env` anpassen:**
|
|
||||||
- `PUBLIC_URL=https://veto.example.de`, damit Einladungs- und Stream-Links stimmen
|
|
||||||
- `TRUST_PROXY=true`, damit die Begrenzung der Anmeldeversuche die echte Client-IP sieht und nicht die des Proxys
|
|
||||||
- **Nicht über den Proxy:**
|
|
||||||
- Die CS2-Spielverbindungen laufen per UDP direkt zum Docker-Host. Für Spieler aus dem Internet braucht es dort Portfreigaben und einen GSLT (`CS2_GSLT`).
|
|
||||||
- Die Logs der CS2-Server können weiter über das LAN kommen. `LOG_RECEIVER_URL` bleibt dann z. B. `http://192.168.178.31:3000`.
|
|
||||||
- **Vor dem Freigeben absichern:** Öffentlich erreichbar sind dann auch `/admin` und, ohne Schlüssel, die Stream-Übersicht. Setze ein starkes `ADMIN_PASSWORD` und einen `STREAM_KEY`.
|
|
||||||
|
|
||||||
Beispiel für nginx:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name veto.example.de;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/veto.example.de/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/veto.example.de/privkey.pem;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://192.168.178.31:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 1h;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ein `502 Bad Gateway` vom Proxy heißt, dass er die Anwendung unter `proxy_pass` nicht erreicht: Sie läuft nicht, oder Adresse bzw. Port stimmen nicht.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Umgebungsvariablen
|
## Umgebungsvariablen
|
||||||
@@ -440,7 +383,6 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
|||||||
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
||||||
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
|
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
|
||||||
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
|
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
|
||||||
| `TRUST_PROXY` | `false` | `true`, wenn die Anwendung hinter einem Reverse-Proxy läuft (echte Client-IP für die Begrenzung der Anmeldeversuche) |
|
|
||||||
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
|
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
|
||||||
|
|
||||||
Benötigte Zugangsdaten für den Echtbetrieb:
|
Benötigte Zugangsdaten für den Echtbetrieb:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
|
|||||||
const { current } = veto;
|
const { current } = veto;
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
useEffect(() => setSelected(null), [current?.index]);
|
useEffect(() => setSelected(null), [current?.index]);
|
||||||
const left = useCountdown(veto.remainingMs);
|
|
||||||
|
|
||||||
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
||||||
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
|
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
|
||||||
@@ -39,7 +38,6 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
|
|||||||
? 'Live-Ansicht – nur Captains können wählen.'
|
? 'Live-Ansicht – nur Captains können wählen.'
|
||||||
: 'Das andere Team ist am Zug.'}
|
: 'Das andere Team ist am Zug.'}
|
||||||
</span>
|
</span>
|
||||||
{left !== null && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
||||||
@@ -131,28 +129,3 @@ function statusText(m, teams) {
|
|||||||
default: return 'Verfügbar';
|
default: return 'Verfügbar';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leiste für die verbleibende Zeit des Veto-Schritts. In den letzten 10 Sekunden wird sie rot. */
|
|
||||||
function TimerBar({ left, totalMs }) {
|
|
||||||
const seconds = Math.ceil(left / 1000);
|
|
||||||
const percent = Math.max(0, Math.min(100, (left / totalMs) * 100));
|
|
||||||
return (
|
|
||||||
<div className={`turn-timer ${left <= 10_000 ? 'is-low' : ''}`} role="timer" aria-label={`Noch ${seconds} Sekunden`}>
|
|
||||||
<div className="turn-bar"><span style={{ width: `${percent}%` }} /></div>
|
|
||||||
<span className="turn-seconds">{seconds} s</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */
|
|
||||||
function useCountdown(ms) {
|
|
||||||
const [left, setLeft] = useState(ms ?? null);
|
|
||||||
useEffect(() => {
|
|
||||||
setLeft(ms ?? null);
|
|
||||||
if (ms == null) return undefined;
|
|
||||||
const started = Date.now();
|
|
||||||
const t = setInterval(() => setLeft(Math.max(0, ms - (Date.now() - started))), 250);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, [ms]);
|
|
||||||
return left;
|
|
||||||
}
|
|
||||||
@@ -271,9 +271,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
|
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
|
||||||
const current = m.series.find((s) => s.number === m.currentMapNumber);
|
|
||||||
const configured = m.server?.status === 'configured';
|
|
||||||
const paused = !!m.server?.paused;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`panel admin-card status-${m.status}`}>
|
<section className={`panel admin-card status-${m.status}`}>
|
||||||
@@ -286,14 +283,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
|
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
|
||||||
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
|
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
|
||||||
</div>
|
</div>
|
||||||
{m.status === 'live' && current && (
|
|
||||||
<div className="admin-live">
|
|
||||||
<span>Map {current.number}: {current.name}</span>
|
|
||||||
<strong>{current.score.A} : {current.score.B}</strong>
|
|
||||||
{current.live?.roundsPlayed != null && <span className="muted">Runde {current.live.roundsPlayed + 1}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{paused && <span className="admin-badge is-paused">Pausiert</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,8 +291,8 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
<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' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
|
||||||
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
|
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
|
||||||
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
|
{(m.server?.status === 'configured') && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
|
||||||
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
|
{(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })}
|
||||||
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
|
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
|
||||||
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
{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' })}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default function MatchPage() {
|
|||||||
|
|
||||||
{viewer.role === 'public' && <PublicNotice />}
|
{viewer.role === 'public' && <PublicNotice />}
|
||||||
|
|
||||||
<div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}>
|
<div className={`match-grid ${state.status !== 'lobby' && viewer.role !== 'public' ? 'stage-wide' : ''}`}>
|
||||||
<TeamCard team={state.teams.A} side="A" {...ctx} />
|
<TeamCard team={state.teams.A} side="A" {...ctx} />
|
||||||
<section className="stage">
|
<section className="stage">
|
||||||
<Stage {...ctx} />
|
<Stage {...ctx} />
|
||||||
@@ -156,8 +156,8 @@ function describeViewer(viewer, teams) {
|
|||||||
|
|
||||||
function Stage(ctx) {
|
function Stage(ctx) {
|
||||||
const { state, isOrg, act, busy } = ctx;
|
const { state, isOrg, act, busy } = ctx;
|
||||||
if (state.viewer.role === 'public' && state.status === 'lobby') {
|
if (state.viewer.role === 'public') {
|
||||||
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Das Veto und der Spielstand erscheinen hier, sobald das Match startet.</p></div>;
|
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
|
||||||
}
|
}
|
||||||
if (state.status === 'lobby') {
|
if (state.status === 'lobby') {
|
||||||
const { A, B } = state.teams;
|
const { A, B } = state.teams;
|
||||||
@@ -230,8 +230,8 @@ function MapPoolPreview({ maps }) {
|
|||||||
function PublicNotice() {
|
function PublicNotice() {
|
||||||
return (
|
return (
|
||||||
<div className="alert">
|
<div className="alert">
|
||||||
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich.
|
Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
|
||||||
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
|
Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { api } from '../lib/api.js';
|
|||||||
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
||||||
import { getAccess } from '../lib/session.js';
|
import { getAccess } from '../lib/session.js';
|
||||||
import { useOverview } from '../lib/useMatch.js';
|
import { useOverview } from '../lib/useMatch.js';
|
||||||
import MapImage from '../components/MapImage.jsx';
|
|
||||||
|
|
||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
|
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
|
||||||
@@ -33,11 +32,7 @@ export default function Overview() {
|
|||||||
return (
|
return (
|
||||||
<section key={g.key} className={`group group-${g.key}`}>
|
<section key={g.key} className={`group group-${g.key}`}>
|
||||||
<h2>{g.title} <span className="count">{list.length}</span></h2>
|
<h2>{g.title} <span className="count">{list.length}</span></h2>
|
||||||
{list.length === 0 ? <p className="muted small">{g.empty}</p> : g.key === 'running' ? (
|
{list.length === 0 ? <p className="muted small">{g.empty}</p> : (
|
||||||
<div className="run-grid">
|
|
||||||
{list.map((m) => <RunningCard key={m.id} match={m} />)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="match-list">
|
<ul className="match-list">
|
||||||
{list.map((m) => <MatchRow key={m.id} match={m} />)}
|
{list.map((m) => <MatchRow key={m.id} match={m} />)}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -84,34 +79,6 @@ function MatchRow({ match: m }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Karte für ein laufendes Match: Map als Hintergrund, Stand groß, Serie und Status darunter. */
|
|
||||||
function RunningCard({ match: m }) {
|
|
||||||
const server = serverLabel(m.server);
|
|
||||||
const map = m.currentMap;
|
|
||||||
const live = map && !map.finished;
|
|
||||||
const bo1 = m.format === 'bo1';
|
|
||||||
return (
|
|
||||||
<Link to={`/m/${m.id}`} className={`run-card ${live ? 'is-live' : ''}`} style={{ '--map': map?.color ?? '#3a4150' }}>
|
|
||||||
{map?.image && <MapImage map={{ image: map.image, color: map.color, name: map.name }} className="run-art" />}
|
|
||||||
<div className="run-top">
|
|
||||||
<span className="run-mode">{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
|
|
||||||
{live ? <span className="live-badge run-live">Live</span> : <span className={`run-status tone-${server.tone}`}>{m.statusLabel}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="run-teams">
|
|
||||||
<span className="run-team a">{m.teamA}</span>
|
|
||||||
<span className="run-score">
|
|
||||||
{map ? <>{map.score.A}<i>:</i>{map.score.B}</> : <i className="run-vs">vs.</i>}
|
|
||||||
</span>
|
|
||||||
<span className="run-team b">{m.teamB}</span>
|
|
||||||
</div>
|
|
||||||
<div className="run-foot">
|
|
||||||
<span>{map ? `Map ${map.number}: ${map.name}` : 'Veto läuft'}</span>
|
|
||||||
{!bo1 && m.seriesScore && <span className="run-series">Serie {m.seriesScore.A}:{m.seriesScore.B}</span>}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CodeEntry() {
|
function CodeEntry() {
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|||||||
@@ -523,34 +523,3 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
.admin-tab:hover { color: var(--text); }
|
.admin-tab:hover { color: var(--text); }
|
||||||
.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
|
.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
|
||||||
|
|
||||||
.turn-timer { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
|
||||||
.turn-bar { flex: 1; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--team) 18%, transparent); overflow: hidden; }
|
|
||||||
.turn-bar span { display: block; height: 100%; background: var(--team); transition: width .25s linear; }
|
|
||||||
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
|
|
||||||
.turn-timer.is-low .turn-bar span { background: var(--error); }
|
|
||||||
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; }
|
|
||||||
.admin-live { display: flex; gap: 10px; align-items: baseline; margin-top: 4px; font-size: .9rem; }
|
|
||||||
.admin-live strong { font-variant-numeric: tabular-nums; }
|
|
||||||
.admin-badge { display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
|
|
||||||
.admin-badge.is-paused { background: color-mix(in srgb, var(--pending) 22%, transparent); color: var(--pending); border: 1px solid var(--pending); }
|
|
||||||
.btn-warn { border-color: var(--pending); color: var(--pending); }
|
|
||||||
/* Laufende Matches in der Übersicht */
|
|
||||||
.run-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
|
||||||
.run-card { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-height: 190px; padding: 16px 18px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--surface); color: var(--text); text-decoration: none; overflow: hidden; isolation: isolate; transition: transform .15s ease, border-color .15s ease; }
|
|
||||||
.run-card:hover { transform: translateY(-2px); border-color: var(--map); }
|
|
||||||
.run-card.is-live { box-shadow: inset 3px 0 0 var(--brand-red); }
|
|
||||||
.run-art { position: absolute; inset: 0; z-index: -1; opacity: .28; }
|
|
||||||
.run-art::after { background: linear-gradient(180deg, rgba(0,0,0,.2), rgba(0,0,0,.92)); }
|
|
||||||
.run-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: .8rem; color: var(--muted); }
|
|
||||||
.run-live { animation: run-pulse 1.6s ease-in-out infinite; }
|
|
||||||
@keyframes run-pulse { 50% { opacity: .55; } }
|
|
||||||
.run-status { font-weight: 600; }
|
|
||||||
.run-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; text-align: center; }
|
|
||||||
.run-team { font: 700 1.25rem var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.run-team.a { color: var(--team-a); text-align: right; }
|
|
||||||
.run-team.b { color: var(--team-b); text-align: left; }
|
|
||||||
.run-score { font: 700 2.25rem var(--display); font-variant-numeric: tabular-nums; }
|
|
||||||
.run-score i { font-style: normal; color: var(--muted); padding: 0 4px; }
|
|
||||||
.run-vs { font-size: 1rem; color: var(--muted); font-style: normal; }
|
|
||||||
.run-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); }
|
|
||||||
.run-series { font-variant-numeric: tabular-nums; }
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Richtet einen Docker-Host für CS2 Mapveto ein. Läuft vom eigenen Rechner aus per SSH.
|
|
||||||
#
|
|
||||||
# Aufruf: bash deploy/docker-host-einrichten.sh <ssh-ziel> [volume]
|
|
||||||
# Beispiel: bash deploy/docker-host-einrichten.sh cs2@192.168.178.111
|
|
||||||
#
|
|
||||||
# Voraussetzungen: SSH-Schlüssel hinterlegt (ohne Passwortabfrage), Docker auf dem Host installiert.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
TARGET="${1:?Aufruf: $0 <ssh-ziel> [volume]}"
|
|
||||||
VOLUME="${2:-cs2-mapveto-gamefiles}"
|
|
||||||
IMAGE="joedwards32/cs2:latest"
|
|
||||||
HOST_PART="${TARGET#*@}"
|
|
||||||
|
|
||||||
echo "→ Verbindung zu $TARGET prüfen"
|
|
||||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "$TARGET" true
|
|
||||||
|
|
||||||
echo "→ Docker prüfen"
|
|
||||||
if ! ssh "$TARGET" "command -v docker >/dev/null 2>&1"; then
|
|
||||||
echo "Docker fehlt auf $HOST_PART. Installation: https://docs.docker.com/engine/install/" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ssh "$TARGET" "docker info >/dev/null"
|
|
||||||
|
|
||||||
echo "→ Freier Platz für Docker (CS2 braucht rund 90 GB)"
|
|
||||||
ssh "$TARGET" "df -BG /var/lib/docker 2>/dev/null | tail -1 || df -BG / | tail -1"
|
|
||||||
|
|
||||||
echo "→ Volume $VOLUME anlegen (falls nicht vorhanden)"
|
|
||||||
ssh "$TARGET" "docker volume inspect $VOLUME >/dev/null 2>&1 || docker volume create $VOLUME >/dev/null"
|
|
||||||
|
|
||||||
echo "→ Image $IMAGE laden"
|
|
||||||
ssh "$TARGET" "docker pull $IMAGE"
|
|
||||||
|
|
||||||
cat <<EOF
|
|
||||||
|
|
||||||
Fertig. Diesen Eintrag in CS2_DOCKER_HOSTS (JSON-Liste in der .env) aufnehmen:
|
|
||||||
|
|
||||||
{"name":"$HOST_PART","dockerHost":"ssh://$TARGET","publicHost":"$HOST_PART","portStart":27100,"portEnd":27139}
|
|
||||||
|
|
||||||
Hinweise:
|
|
||||||
- name muss pro Host eindeutig sein.
|
|
||||||
- Ports 27100–27139 dürfen auf dem Host frei sein. Pro Host sind bis zu 20 Matches möglich.
|
|
||||||
- Der erste Server-Start lädt die CS2-Spieldateien (rund 60 bis 90 GB). Das dauert lange.
|
|
||||||
EOF
|
|
||||||
Binary file not shown.
+1
-5
@@ -20,12 +20,9 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
|||||||
const service = new MatchService({ db, pools, config, notifier });
|
const service = new MatchService({ db, pools, config, notifier });
|
||||||
|
|
||||||
let manager;
|
let manager;
|
||||||
const adapter = adapterOverride ?? createServerAdapter(config, {
|
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
|
||||||
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
|
|
||||||
});
|
|
||||||
manager = new ServerManager({ db, service, adapter, config, logger });
|
manager = new ServerManager({ db, service, adapter, config, logger });
|
||||||
manager.init();
|
manager.init();
|
||||||
service.startVetoTimer();
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
if (config.trustProxy) app.set('trust proxy', 1);
|
if (config.trustProxy) app.set('trust proxy', 1);
|
||||||
@@ -57,7 +54,6 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
|||||||
},
|
},
|
||||||
async close() {
|
async close() {
|
||||||
manager.stopLoops();
|
manager.stopLoops();
|
||||||
service.stopVetoTimer();
|
|
||||||
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
|
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
|
||||||
await manager.idle(5000);
|
await manager.idle(5000);
|
||||||
io.disposeRealtime();
|
io.disposeRealtime();
|
||||||
|
|||||||
@@ -22,32 +22,6 @@ function bool(name, fallback) {
|
|||||||
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
|
return ['1', 'true', 'yes', 'ja', 'on'].includes(raw.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDockerHosts(raw) {
|
|
||||||
if (!raw) return null;
|
|
||||||
let list;
|
|
||||||
try {
|
|
||||||
list = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
throw new Error('CS2_DOCKER_HOSTS ist kein gültiges JSON');
|
|
||||||
}
|
|
||||||
if (!Array.isArray(list) || list.length === 0) throw new Error('CS2_DOCKER_HOSTS braucht mindestens einen Eintrag');
|
|
||||||
const names = new Set();
|
|
||||||
return list.map((h) => {
|
|
||||||
if (!h.name || !h.publicHost) throw new Error('Jeder Docker-Host braucht name und publicHost');
|
|
||||||
if (names.has(h.name)) throw new Error(`Docker-Host-Name "${h.name}" kommt doppelt vor`);
|
|
||||||
names.add(h.name);
|
|
||||||
return {
|
|
||||||
name: String(h.name),
|
|
||||||
dockerHost: h.dockerHost ?? null,
|
|
||||||
publicHost: String(h.publicHost),
|
|
||||||
rconHost: h.rconHost ?? h.publicHost,
|
|
||||||
portRangeStart: h.portStart == null ? undefined : Number(h.portStart),
|
|
||||||
portRangeEnd: h.portEnd == null ? undefined : Number(h.portEnd),
|
|
||||||
volume: h.volume ?? undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function str(name, fallback = '') {
|
function str(name, fallback = '') {
|
||||||
const raw = process.env[name];
|
const raw = process.env[name];
|
||||||
return raw === undefined || raw === '' ? fallback : raw;
|
return raw === undefined || raw === '' ? fallback : raw;
|
||||||
@@ -78,8 +52,6 @@ export function loadConfig(overrides = {}) {
|
|||||||
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
||||||
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
|
|
||||||
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
|
|
||||||
|
|
||||||
match: {
|
match: {
|
||||||
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
||||||
@@ -111,9 +83,6 @@ export function loadConfig(overrides = {}) {
|
|||||||
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
|
volume: str('CS2_DOCKER_VOLUME', 'cs2-mapveto-gamefiles'),
|
||||||
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
|
extraArgs: str('CS2_DOCKER_EXTRA_ARGS'),
|
||||||
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20),
|
startTimeoutMinutes: int('CS2_START_TIMEOUT_MINUTES', 20),
|
||||||
// Optional: mehrere Docker-Hosts als JSON-Liste, z. B.
|
|
||||||
// [{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139}]
|
|
||||||
hosts: parseDockerHosts(str('CS2_DOCKER_HOSTS')),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
staticPool: {
|
staticPool: {
|
||||||
|
|||||||
@@ -128,6 +128,4 @@ function migrate(db) {
|
|||||||
for (const [name, type] of add) {
|
for (const [name, type] of add) {
|
||||||
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
|
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
|
||||||
}
|
}
|
||||||
const serverColumns = columns('servers');
|
|
||||||
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
|
||||||
}
|
}
|
||||||
@@ -351,58 +351,6 @@ export class MatchService extends EventEmitter {
|
|||||||
this.changed(viewer.matchId);
|
this.changed(viewer.matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ablaufzeitpunkt des aktuellen Veto-Schritts (ms), null wenn keine Frist gilt. */
|
|
||||||
vetoDeadline(match) {
|
|
||||||
if (!this.config.vetoStepSeconds || match.status !== 'veto') return null;
|
|
||||||
const actions = this.vetoActions(match.id);
|
|
||||||
const since = actions.length ? actions[actions.length - 1].created_at : match.veto_started_at;
|
|
||||||
return since == null ? null : since + this.config.vetoStepSeconds * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
vetoRemainingMs(match) {
|
|
||||||
const deadline = this.vetoDeadline(match);
|
|
||||||
return deadline === null ? null : Math.max(0, deadline - this.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Führt den aktuellen Veto-Schritt für das Team am Zug aus: zufällige Map bzw. zufällige Seite. */
|
|
||||||
autoVetoStep(match) {
|
|
||||||
const state = this.vetoState(match);
|
|
||||||
if (state.done || !state.current) return;
|
|
||||||
const { type, team, index } = state.current;
|
|
||||||
const pick = (list) => list[Math.floor(Math.random() * list.length)];
|
|
||||||
const viewer = {
|
|
||||||
matchId: match.id, role: 'captain', isOrganizer: false, isCaptain: true, isSpectator: false, playerId: null, team,
|
|
||||||
};
|
|
||||||
const action = type === 'side'
|
|
||||||
? { stepIndex: index, side: pick(['CT', 'T']) }
|
|
||||||
: { stepIndex: index, map: pick(state.maps.filter((m) => m.status === 'available')).id };
|
|
||||||
this.log(match.id, 'veto', `Zeit abgelaufen – ${this.teamName(match, team)} wird automatisch vertreten`);
|
|
||||||
this.vetoAction(viewer, action);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Läuft die Frist eines Veto-Schritts ab, führt das Backend den Schritt aus. */
|
|
||||||
autoVetoTimeouts() {
|
|
||||||
for (const match of this.db.prepare("SELECT * FROM matches WHERE status = 'veto'").all()) {
|
|
||||||
const deadline = this.vetoDeadline(match);
|
|
||||||
if (deadline === null || this.now() < deadline) continue;
|
|
||||||
try {
|
|
||||||
this.autoVetoStep(match);
|
|
||||||
} catch (err) {
|
|
||||||
this.log(match.id, 'veto', `Automatischer Schritt fehlgeschlagen: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startVetoTimer() {
|
|
||||||
if (!this.config.vetoStepSeconds || this.vetoTimer) return;
|
|
||||||
this.vetoTimer = setInterval(() => this.autoVetoTimeouts(), 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
stopVetoTimer() {
|
|
||||||
clearInterval(this.vetoTimer);
|
|
||||||
this.vetoTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
vetoAction(viewer, { stepIndex, map, side }) {
|
vetoAction(viewer, { stepIndex, map, side }) {
|
||||||
this.require(viewer, 'captain');
|
this.require(viewer, 'captain');
|
||||||
let finished = false;
|
let finished = false;
|
||||||
@@ -791,14 +739,7 @@ export class MatchService extends EventEmitter {
|
|||||||
return {
|
return {
|
||||||
seriesScore: maps.length ? seriesScore(maps) : null,
|
seriesScore: maps.length ? seriesScore(maps) : null,
|
||||||
currentMap: current && (current.status === 'live' || current.status === 'done')
|
currentMap: current && (current.status === 'live' || current.status === 'done')
|
||||||
? {
|
? { number: current.number, name: this.mapName(m, current.map), score: { A: current.score_a, B: current.score_b }, finished: current.status === 'done' }
|
||||||
number: current.number,
|
|
||||||
name: this.mapName(m, current.map),
|
|
||||||
image: JSON.parse(m.maps).find((x) => x.id === current.map)?.image ?? null,
|
|
||||||
color: JSON.parse(m.maps).find((x) => x.id === current.map)?.color ?? null,
|
|
||||||
score: { A: current.score_a, B: current.score_b },
|
|
||||||
finished: current.status === 'done',
|
|
||||||
}
|
|
||||||
: null,
|
: null,
|
||||||
id: m.id,
|
id: m.id,
|
||||||
teamA: m.team_a_name,
|
teamA: m.team_a_name,
|
||||||
@@ -892,7 +833,7 @@ export class MatchService extends EventEmitter {
|
|||||||
requireFullTeams: this.config.requireFullTeams,
|
requireFullTeams: this.config.requireFullTeams,
|
||||||
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
|
autoAdvanceSeconds: this.config.autoAdvanceSeconds,
|
||||||
maps: pool.maps,
|
maps: pool.maps,
|
||||||
veto: publicView && match.status === 'lobby'
|
veto: publicView
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
started: match.status !== 'lobby',
|
started: match.status !== 'lobby',
|
||||||
@@ -900,8 +841,6 @@ export class MatchService extends EventEmitter {
|
|||||||
steps: veto.steps,
|
steps: veto.steps,
|
||||||
maps: veto.maps,
|
maps: veto.maps,
|
||||||
current: match.status === 'veto' ? veto.current : null,
|
current: match.status === 'veto' ? veto.current : null,
|
||||||
remainingMs: this.vetoRemainingMs(match),
|
|
||||||
stepSeconds: this.config.vetoStepSeconds,
|
|
||||||
done: veto.done,
|
done: veto.done,
|
||||||
history: actions.map((a) => ({
|
history: actions.map((a) => ({
|
||||||
index: a.idx,
|
index: a.idx,
|
||||||
@@ -951,7 +890,6 @@ export class MatchService extends EventEmitter {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
message: row.message,
|
message: row.message,
|
||||||
playerCount: row.player_count,
|
playerCount: row.player_count,
|
||||||
paused: !!row.paused,
|
|
||||||
attempts: row.attempts,
|
attempts: row.attempts,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
|
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DemoServerAdapter } from './demoAdapter.js';
|
import { DemoServerAdapter } from './demoAdapter.js';
|
||||||
import { DockerHostPool } from './dockerAdapter.js';
|
import { DockerServerAdapter } from './dockerAdapter.js';
|
||||||
import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
|
import { StaticPoolServerAdapter } from './staticPoolAdapter.js';
|
||||||
|
|
||||||
export function createServerAdapter(config, deps) {
|
export function createServerAdapter(config, deps) {
|
||||||
@@ -37,7 +37,7 @@ export function createServerAdapter(config, deps) {
|
|||||||
case 'demo':
|
case 'demo':
|
||||||
return new DemoServerAdapter(config.demo);
|
return new DemoServerAdapter(config.demo);
|
||||||
case 'docker':
|
case 'docker':
|
||||||
return new DockerHostPool(config.docker, deps);
|
return new DockerServerAdapter(config.docker, deps);
|
||||||
case 'static':
|
case 'static':
|
||||||
return new StaticPoolServerAdapter(config.staticPool, deps);
|
return new StaticPoolServerAdapter(config.staticPool, deps);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ export class DockerServerAdapter {
|
|||||||
|
|
||||||
async docker(args, timeout = 60_000) {
|
async docker(args, timeout = 60_000) {
|
||||||
if (this.runDocker) return this.runDocker(args);
|
if (this.runDocker) return this.runDocker(args);
|
||||||
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024 });
|
||||||
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
|
|
||||||
return stdout.trim();
|
return stdout.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,14 +137,7 @@ export class DockerServerAdapter {
|
|||||||
await this.docker(args, 15 * 60_000);
|
await this.docker(args, 15 * 60_000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handle: {
|
handle: { container, rconHost: this.options.rconHost, port, startedAt: Date.now(), rconPassword: secrets.rconPassword },
|
||||||
container,
|
|
||||||
hostName: this.options.name,
|
|
||||||
rconHost: this.options.rconHost,
|
|
||||||
port,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
rconPassword: secrets.rconPassword,
|
|
||||||
},
|
|
||||||
host: this.options.host,
|
host: this.options.host,
|
||||||
port,
|
port,
|
||||||
tvPort,
|
tvPort,
|
||||||
@@ -200,99 +192,3 @@ export class DockerServerAdapter {
|
|||||||
await this.docker(['rm', '-f', handle.container]).catch(() => {});
|
await this.docker(['rm', '-f', handle.container]).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Mehrere Docker-Hosts. Jedes Match läuft auf dem Host mit den meisten freien Port-Paaren,
|
|
||||||
* der Host steht im Handle und damit im Match. Start, Status, Konfiguration und Stopp gehen
|
|
||||||
* immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen.
|
|
||||||
*/
|
|
||||||
export class DockerHostPool {
|
|
||||||
constructor(options, { getUsedPorts, runDocker } = {}) {
|
|
||||||
this.name = 'docker';
|
|
||||||
this.simulated = false;
|
|
||||||
this.depsGetUsedPorts = getUsedPorts;
|
|
||||||
const hosts = options.hosts ?? [{
|
|
||||||
name: 'default',
|
|
||||||
dockerHost: process.env.DOCKER_HOST || null,
|
|
||||||
publicHost: options.host,
|
|
||||||
rconHost: options.rconHost,
|
|
||||||
portRangeStart: options.portRangeStart,
|
|
||||||
portRangeEnd: options.portRangeEnd,
|
|
||||||
volume: options.volume,
|
|
||||||
}];
|
|
||||||
// Fehlende Angaben kommen aus den allgemeinen Docker-Einstellungen
|
|
||||||
this.hosts = hosts.map((h) => {
|
|
||||||
const host = {
|
|
||||||
name: h.name,
|
|
||||||
dockerHost: h.dockerHost ?? null,
|
|
||||||
publicHost: h.publicHost ?? options.host,
|
|
||||||
rconHost: h.rconHost ?? options.rconHost,
|
|
||||||
portRangeStart: h.portRangeStart ?? options.portRangeStart,
|
|
||||||
portRangeEnd: h.portRangeEnd ?? options.portRangeEnd,
|
|
||||||
volume: h.volume ?? options.volume,
|
|
||||||
};
|
|
||||||
return { ...options, ...host, host: host.publicHost };
|
|
||||||
});
|
|
||||||
this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, {
|
|
||||||
getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()),
|
|
||||||
runDocker,
|
|
||||||
})]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Freie Port-Paare auf einem Host. */
|
|
||||||
freeSlots(host) {
|
|
||||||
const used = this.getUsedPorts(host.publicHost);
|
|
||||||
let free = 0;
|
|
||||||
for (let p = host.portRangeStart; p + 1 <= host.portRangeEnd; p += 2) {
|
|
||||||
if (!used.has(p) && !used.has(p + 1)) free += 1;
|
|
||||||
}
|
|
||||||
return free;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUsedPorts(publicHost) {
|
|
||||||
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
pickHost() {
|
|
||||||
const best = this.hosts
|
|
||||||
.map((h) => ({ host: h, free: this.freeSlots(h) }))
|
|
||||||
.filter((x) => x.free > 0)
|
|
||||||
.sort((a, b) => b.free - a.free)[0];
|
|
||||||
if (!best) throw new Error('Kein Docker-Host mit freien Ports');
|
|
||||||
return best.host;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(args) {
|
|
||||||
return this.adapters.get(this.pickHost().name).start(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
adapterFor(handle) {
|
|
||||||
const name = handle?.hostName ?? this.hosts[0].name;
|
|
||||||
const adapter = this.adapters.get(name);
|
|
||||||
if (!adapter) throw new Error(`Docker-Host "${name}" ist nicht (mehr) konfiguriert`);
|
|
||||||
return adapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
async status(handle) {
|
|
||||||
return this.adapterFor(handle).status(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
async configure(handle, plan) {
|
|
||||||
return this.adapterFor(handle).configure(handle, plan);
|
|
||||||
}
|
|
||||||
|
|
||||||
async listRoundBackups(handle, matchId) {
|
|
||||||
return this.adapterFor(handle).listRoundBackups(handle, matchId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadRoundBackup(handle, file) {
|
|
||||||
return this.adapterFor(handle).loadRoundBackup(handle, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendCommands(handle, commands) {
|
|
||||||
return this.adapterFor(handle).sendCommands(handle, commands);
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(handle) {
|
|
||||||
return this.adapterFor(handle).stop(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -160,16 +160,6 @@ export class ServerManager {
|
|||||||
this.service.changed(matchId);
|
this.service.changed(matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
|
|
||||||
usedPortsOn(host) {
|
|
||||||
const used = new Set();
|
|
||||||
for (const r of this.db.prepare(`SELECT port, tv_port FROM servers WHERE host = ? AND status != 'stopped' AND port IS NOT NULL`).all(host)) {
|
|
||||||
used.add(r.port);
|
|
||||||
used.add(r.tv_port);
|
|
||||||
}
|
|
||||||
return used;
|
|
||||||
}
|
|
||||||
|
|
||||||
usedPorts() {
|
usedPorts() {
|
||||||
const used = new Set();
|
const used = new Set();
|
||||||
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
|
for (const r of this.db.prepare(`SELECT host, port, tv_port FROM servers WHERE status != 'stopped' AND port IS NOT NULL`).all()) {
|
||||||
@@ -318,8 +308,7 @@ export class ServerManager {
|
|||||||
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
|
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
|
||||||
|
|
||||||
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
|
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
|
||||||
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
|
this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
||||||
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
|
|
||||||
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
|
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
|
||||||
|
|
||||||
const plan = buildMapCommands({
|
const plan = buildMapCommands({
|
||||||
@@ -429,8 +418,8 @@ export class ServerManager {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
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.update(viewer.matchId, { paused: paused ? 1 : 0 });
|
|
||||||
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
|
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
|
||||||
|
this.service.changed(viewer.matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
||||||
@@ -474,7 +463,7 @@ export class ServerManager {
|
|||||||
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);
|
||||||
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
|
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
|
||||||
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
|
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { DockerHostPool } from '../src/servers/dockerAdapter.js';
|
|
||||||
|
|
||||||
const secrets = { rconPassword: 'Rcon123', serverPassword: 'Pw123', tvPassword: 'Tv123' };
|
|
||||||
|
|
||||||
const hostA = { name: 'a', publicHost: '10.0.0.1', portRangeStart: 27100, portRangeEnd: 27103 }; // 2 Paare
|
|
||||||
const hostB = { name: 'b', publicHost: '10.0.0.2', portRangeStart: 27100, portRangeEnd: 27139 }; // 20 Paare
|
|
||||||
|
|
||||||
function options(hosts) {
|
|
||||||
return {
|
|
||||||
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9',
|
|
||||||
portRangeStart: 27015, portRangeEnd: 27115, gslt: '', volume: 'cs2-mapveto-gamefiles', extraArgs: '',
|
|
||||||
startTimeoutMinutes: 1, hosts,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Docker-Aufrufe merken, Container gelten sofort als fertig. */
|
|
||||||
function fakeDocker() {
|
|
||||||
const calls = [];
|
|
||||||
const run = async (args) => {
|
|
||||||
calls.push(args);
|
|
||||||
if (args[0] === 'inspect') return 'running';
|
|
||||||
if (args[0] === 'logs') return 'Starting CS2 Dedicated Server';
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
return { run, calls };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nur die Konfiguration der Hosts, ohne auf echte Container zu warten. */
|
|
||||||
function pool(hosts, used = {}) {
|
|
||||||
const docker = fakeDocker();
|
|
||||||
const p = new DockerHostPool(options(hosts), {
|
|
||||||
getUsedPorts: (publicHost) => new Set(used[publicHost] ?? []),
|
|
||||||
runDocker: docker.run,
|
|
||||||
});
|
|
||||||
for (const a of p.adapters.values()) a.waitForUpdateCheck = async () => {};
|
|
||||||
return { pool: p, calls: docker.calls };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('Ohne CS2_DOCKER_HOSTS gibt es genau einen Host, wie bisher', () => {
|
|
||||||
const { pool: p } = pool(null);
|
|
||||||
assert.equal(p.hosts.length, 1);
|
|
||||||
assert.equal(p.hosts[0].publicHost, '10.0.0.9');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Ein Match landet auf dem Host mit den meisten freien Ports', async () => {
|
|
||||||
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': [27100] }); // A hat ein Paar belegt, B ist frei
|
|
||||||
const r = await p.start({ matchId: 'm1', mode: 'competitive', secrets });
|
|
||||||
assert.equal(r.handle.hostName, 'b');
|
|
||||||
assert.equal(r.host, '10.0.0.2');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Ein voller Host wird übersprungen', async () => {
|
|
||||||
const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i); // alle Ports von A belegt
|
|
||||||
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA });
|
|
||||||
const r = await p.start({ matchId: 'm2', mode: 'wingman', secrets });
|
|
||||||
assert.equal(r.handle.hostName, 'b');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Sind alle Hosts voll, gibt es einen klaren Fehler', async () => {
|
|
||||||
const fullA = Array.from({ length: 4 }, (_, i) => 27100 + i);
|
|
||||||
const fullB = Array.from({ length: 40 }, (_, i) => 27100 + i);
|
|
||||||
const { pool: p } = pool([hostA, hostB], { '10.0.0.1': fullA, '10.0.0.2': fullB });
|
|
||||||
await assert.rejects(p.start({ matchId: 'm3', mode: 'wingman', secrets }), /Kein Docker-Host mit freien Ports/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Status, Stopp und Befehle gehen an den Host des Matches', async () => {
|
|
||||||
const { pool: p, calls } = pool([hostA, hostB], { '10.0.0.1': [27100] });
|
|
||||||
const r = await p.start({ matchId: 'm4', mode: 'competitive', secrets });
|
|
||||||
calls.length = 0;
|
|
||||||
await p.stop(r.handle);
|
|
||||||
assert.deepEqual(calls.at(-1)[0], 'rm');
|
|
||||||
assert.equal(calls.at(-1).at(-1), r.handle.container);
|
|
||||||
// Ein Handle von einem Host, der nicht mehr konfiguriert ist, wird abgelehnt
|
|
||||||
await assert.rejects(p.status({ ...r.handle, hostName: 'gelöscht' }), /nicht \(mehr\) konfiguriert/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Alte Handles ohne Host-Namen laufen auf dem ersten Host', () => {
|
|
||||||
const { pool: p } = pool([hostA, hostB]);
|
|
||||||
assert.equal(p.adapterFor({ container: 'x' }), p.adapters.get('a'));
|
|
||||||
});
|
|
||||||
-84
@@ -1,84 +0,0 @@
|
|||||||
10/25/2026 - 18:06:44.021 - rcon from "192.168.178.31:54420": command "status"
|
|
||||||
10/25/2026 - 18:06:48.883 - "Spieler1<0><[U:1:1000001]><>" entered the game
|
|
||||||
10/25/2026 - 18:07:05.771 - "Spieler1<0><[U:1:1000001]>" switched from team <Unassigned> to <CT>
|
|
||||||
10/25/2026 - 18:07:18.570 - "Spieler1<0><[U:1:1000001]><CT>" [1331 132 138] killed "Sarge<10><BOT><TERRORIST>" [1410 322 128] with "usp_silencer" (headshot)
|
|
||||||
10/25/2026 - 18:07:52.100 - rcon from "192.168.178.31:61554": command "mp_warmup_pausetimer 0"
|
|
||||||
10/25/2026 - 18:07:52.381 - rcon from "192.168.178.31:61554": command "mp_warmup_end"
|
|
||||||
10/25/2026 - 18:07:52.396 - Starting Freeze period
|
|
||||||
10/25/2026 - 18:07:52.396 - JSON_BEGIN{
|
|
||||||
10/25/2026 - 18:07:52.396 - "name": "round_stats",
|
|
||||||
10/25/2026 - 18:07:52.396 - "round_number" : "1",
|
|
||||||
10/25/2026 - 18:07:52.396 - "map" : "de_inferno",
|
|
||||||
10/25/2026 - 18:07:52.396 - }}JSON_END
|
|
||||||
10/25/2026 - 18:07:52.396 - World triggered "Match_Start" on "de_inferno"
|
|
||||||
10/25/2026 - 18:07:52.396 - Team playing "CT": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:07:52.396 - Team playing "TERRORIST": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:07:52.396 - "Darryl<12><BOT><TERRORIST>" triggered "Got_The_Bomb"
|
|
||||||
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "CT": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:07:52.396 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:07:52.396 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: 0
|
|
||||||
10/25/2026 - 18:08:01.869 - World triggered "Round_Start"
|
|
||||||
10/25/2026 - 18:08:24.463 - "Darryl<12><BOT><TERRORIST>" triggered "Planted_The_Bomb" at bombsite A
|
|
||||||
10/25/2026 - 18:08:30.541 - "Darryl<12><BOT><TERRORIST>" [2059 398 160] killed "Maximus<11><BOT><CT>" [2356 255 126] with "glock"
|
|
||||||
10/25/2026 - 18:08:30.541 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "0") (T "1")
|
|
||||||
10/25/2026 - 18:08:30.541 - Team "CT" scored "0" with "3" players
|
|
||||||
10/25/2026 - 18:08:30.541 - Team "TERRORIST" scored "1" with "2" players
|
|
||||||
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "CT": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:08:30.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:08:30.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
|
|
||||||
10/25/2026 - 18:08:30.541 - World triggered "Round_End"
|
|
||||||
10/25/2026 - 18:08:33.541 - Starting Freeze period
|
|
||||||
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "CT": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:08:33.541 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:08:33.541 - MatchStatus: Score: 0:1 on map "de_inferno" RoundsPlayed: 1
|
|
||||||
10/25/2026 - 18:08:36.543 - World triggered "Round_Start"
|
|
||||||
10/25/2026 - 18:09:08.839 - "Spieler1<0><[U:1:1000001]><CT>" [2254 1009 159] killed "Sarge<10><BOT><TERRORIST>" [2287 352 132] with "famas" (headshot)
|
|
||||||
10/25/2026 - 18:09:08.839 - Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "1")
|
|
||||||
10/25/2026 - 18:09:08.839 - Team "CT" scored "1" with "3" players
|
|
||||||
10/25/2026 - 18:09:08.839 - Team "TERRORIST" scored "1" with "3" players
|
|
||||||
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "CT": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:09:08.839 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:09:08.839 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
|
|
||||||
10/25/2026 - 18:09:08.839 - World triggered "Round_End"
|
|
||||||
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]>" switched from team <CT> to <TERRORIST>
|
|
||||||
10/25/2026 - 18:09:17.339 - "Sarge<10><BOT>" switched from team <TERRORIST> to <CT>
|
|
||||||
10/25/2026 - 18:09:17.339 - Starting Freeze period
|
|
||||||
10/25/2026 - 18:09:17.339 - "Spieler1<0><[U:1:1000001]><TERRORIST>" triggered "Got_The_Bomb"
|
|
||||||
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "CT": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:09:17.339 - MatchStatus: Team playing "TERRORIST": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:09:17.339 - MatchStatus: Score: 1:1 on map "de_inferno" RoundsPlayed: 2
|
|
||||||
10/25/2026 - 18:09:26.841 - World triggered "Round_Start"
|
|
||||||
10/25/2026 - 18:09:35.935 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1167 474 111] killed "Darryl<12><BOT><CT>" [1326 435 129] with "deagle" (headshot)
|
|
||||||
10/25/2026 - 18:09:35.935 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "2")
|
|
||||||
10/25/2026 - 18:09:35.935 - Team "CT" scored "1" with "3" players
|
|
||||||
10/25/2026 - 18:09:35.935 - Team "TERRORIST" scored "2" with "4" players
|
|
||||||
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "CT": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:09:35.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:09:35.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
|
|
||||||
10/25/2026 - 18:09:35.935 - World triggered "Round_End"
|
|
||||||
10/25/2026 - 18:09:38.935 - Starting Freeze period
|
|
||||||
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "CT": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:09:38.935 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:09:38.935 - MatchStatus: Score: 1:2 on map "de_inferno" RoundsPlayed: 3
|
|
||||||
10/25/2026 - 18:09:41.936 - World triggered "Round_Start"
|
|
||||||
10/25/2026 - 18:09:59.311 - "Spieler1<0><[U:1:1000001]><TERRORIST>" [1364 124 132] killed "Sarge<10><BOT><CT>" [1746 -51 135] with "ak47"
|
|
||||||
10/25/2026 - 18:09:59.311 - Team "TERRORIST" triggered "SFUI_Notice_Terrorists_Win" (CT "1") (T "3")
|
|
||||||
10/25/2026 - 18:09:59.311 - Team "CT" scored "1" with "5" players
|
|
||||||
10/25/2026 - 18:09:59.311 - Team "TERRORIST" scored "3" with "2" players
|
|
||||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
|
|
||||||
10/25/2026 - 18:09:59.311 - World triggered "Round_End"
|
|
||||||
10/25/2026 - 18:09:59.311 - ACCOLADE, FINAL: {killreward}, Spieler1<0>, VALUE: 1800.000000, POS: 1, SCORE: 70.000000
|
|
||||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "CT": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:09:59.311 - MatchStatus: Team playing "TERRORIST": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:09:59.311 - MatchStatus: Score: 1:3 on map "de_inferno" RoundsPlayed: 4
|
|
||||||
10/25/2026 - 18:09:59.311 - Game Over: scrimcomp2v2 de_inferno score 1:3 after 3 min
|
|
||||||
10/25/2026 - 18:10:24.326 - World triggered "Match_Start" on "de_inferno"
|
|
||||||
10/25/2026 - 18:10:24.326 - Team playing "CT": MaxLAN Rot
|
|
||||||
10/25/2026 - 18:10:24.326 - Team playing "TERRORIST": MaxLAN Blau
|
|
||||||
10/25/2026 - 18:10:24.326 - MatchStatus: Score: 0:0 on map "de_inferno" RoundsPlayed: -1
|
|
||||||
10/25/2026 - 18:10:26.336 - World triggered "Warmup_Start"
|
|
||||||
10/25/2026 - 18:10:31.304 - World triggered "Game_Commencing"
|
|
||||||
10/25/2026 - 18:10:31.304 - World triggered "Match_Start" on "de_inferno"
|
|
||||||
10/25/2026 - 18:10:33.293 - World triggered "Warmup_Start"
|
|
||||||
@@ -60,8 +60,8 @@ test('Seitenzuordnung nach dem Rundenende hat Vorrang, veraltete Zeilen zu Runde
|
|||||||
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
|
assert.deepEqual(s.rounds.map((r) => r.winner), ['A', 'B']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Echte, anonymisierte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
test('Echte Logzeilen eines CS2-Servers (Wingman, 4 Runden, Halbzeit nach 2)', () => {
|
||||||
const lines = fs.readFileSync(path.join(here, 'fixtures', 'wingman-anonym.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
const lines = fs.readFileSync(path.join(here, 'fixtures', 'echte-logs-wingman.log'), 'utf8').split(/\r?\n/).filter(Boolean);
|
||||||
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
|
const warmupEnd = lines.findIndex((l) => l.includes('command "mp_warmup_end"'));
|
||||||
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
|
const ctx = { teamNames: { A: 'MaxLAN Blau', B: 'MaxLAN Rot' }, maxRounds: 4, overtimeRounds: 6 };
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user