Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -75,14 +75,14 @@ Die Tests decken Veto-Engine und Ablaufvalidierung, RCON-Protokoll (gegen einen
|
|||||||
6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.
|
6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.
|
||||||
- Spieler sehen danach den kopierbaren Konsolenbefehl `password …; connect host:port` und einen `steam://connect`-Link.
|
- Spieler sehen danach den kopierbaren Konsolenbefehl `password …; connect host:port` und einen `steam://connect`-Link.
|
||||||
- Zuschauer sehen den CSTV-Zugang (eigener Port, eigenes Passwort, Verzögerung).
|
- Zuschauer sehen den CSTV-Zugang (eigener Port, eigenes Passwort, Verzögerung).
|
||||||
7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
|
7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), das laufende Match pausieren und fortsetzen (`mp_pause_match`, `mp_unpause_match`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
|
||||||
8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“.
|
8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“.
|
||||||
|
|
||||||
### Rollen und Berechtigungen
|
### Rollen und Berechtigungen
|
||||||
|
|
||||||
| Rolle | Zugang über | Darf |
|
| Rolle | Zugang über | Darf |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung |
|
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, pausieren/fortsetzen, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung, die Match-Übersicht |
|
||||||
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe |
|
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe |
|
||||||
| Spieler | Teamcode | bereit melden, Team verlassen; sieht die Spieler-Verbindungsdaten nach dem Veto |
|
| Spieler | Teamcode | bereit melden, Team verlassen; sieht die Spieler-Verbindungsdaten nach dem Veto |
|
||||||
| Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
|
| Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
|
||||||
@@ -201,9 +201,22 @@ Die Serververwaltung ist hinter [`server/src/servers/adapter.js`](server/src/ser
|
|||||||
- Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
|
- Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
|
||||||
3. **`configure(handle, plan)`**: in zwei Phasen.
|
3. **`configure(handle, plan)`**: in zwei Phasen.
|
||||||
1. `game_type`, `game_mode`, `sv_password`, `tv_enable`, `tv_delay`, `tv_password`, dann `changelevel <map>`.
|
1. `game_type`, `game_mode`, `sv_password`, `tv_enable`, `tv_delay`, `tv_password`, dann `changelevel <map>`.
|
||||||
2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`).
|
2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`) und die Turnierregeln (siehe unten).
|
||||||
|
|
||||||
Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt.
|
Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt.
|
||||||
|
|
||||||
|
**Turnierregeln:** Jeder konfigurierte Server setzt außerdem diese Werte, die sich an FACEIT- und ESL-Richtwerten orientieren:
|
||||||
|
|
||||||
|
| Befehl | Wert | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `sv_pausable` | `1` | erlaubt Pausen (`mp_pause_match`) |
|
||||||
|
| `mp_halftime` | `1` | Seitenwechsel zur Halbzeit |
|
||||||
|
| `mp_freezetime` | `20` | Freezetime in Sekunden |
|
||||||
|
| `mp_buytime` | `20` | Kaufzeit in Sekunden |
|
||||||
|
| `mp_startmoney` | `800` | Startgeld |
|
||||||
|
| `mp_roundtime` | `1.92` | Rundenzeit in Minuten (1:55) |
|
||||||
|
|
||||||
|
Die Werte stammen nicht aus einer Regel eines konkreten Turniers. Vor einem echten Turnier bitte mit dem Regelwerk des Veranstalters abgleichen und in `server/src/servers/cs2Config.js` anpassen. Ob `sv_pausable` und die Pause-Befehle am echten Server wirken, ist noch nicht getestet.
|
||||||
4. **`stop(handle)`**: Server beenden bzw. freigeben.
|
4. **`stop(handle)`**: Server beenden bzw. freigeben.
|
||||||
|
|
||||||
### Option A: Docker (`SERVER_ADAPTER=docker`)
|
### Option A: Docker (`SERVER_ADAPTER=docker`)
|
||||||
@@ -314,12 +327,15 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
|
|||||||
- **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
|
- **Raster:** passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
|
||||||
- **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.
|
||||||
- **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.
|
||||||
|
|
||||||
### Stream-Übersicht eines Matches
|
### Stream-Übersicht eines Matches
|
||||||
|
|
||||||
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
|
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
|
||||||
|
|
||||||
|
Für die Übersicht eines einzelnen Matches ohne Zuschauercode gibt es `/stream/match/<matchId>`. Der Organisator findet den Link unter „Match-Übersicht“ im Bereich Einladungen. Ist `STREAM_KEY` gesetzt, hängt der Link den Schlüssel an, damit Organisatoren die Seite öffnen können.
|
||||||
|
|
||||||
- **Format:** 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit `?bg=transparent` ist der Hintergrund durchsichtig.
|
- **Format:** 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit `?bg=transparent` ist der Hintergrund durchsichtig.
|
||||||
- **Ablauf:** Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste).
|
- **Ablauf:** Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste).
|
||||||
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
|
- **Laufband:** zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
|
||||||
@@ -392,6 +408,6 @@ Alle Aktionen laufen per `POST` mit `Authorization: Bearer <token>`. Der Token i
|
|||||||
| `…/ready`, `…/leave` | Spieler |
|
| `…/ready`, `…/leave` | Spieler |
|
||||||
| `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug |
|
| `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug |
|
||||||
| `…/captain` `{playerId}` | Organisator, Captain des Teams |
|
| `…/captain` `{playerId}` | Organisator, Captain des Teams |
|
||||||
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
|
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/server/pause`, `…/server/resume`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
|
||||||
|
|
||||||
Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`.
|
Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`.
|
||||||
@@ -29,6 +29,8 @@ export default function OrganizerPanel({ state, act, busy, matchId, token }) {
|
|||||||
<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} />
|
||||||
|
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,6 +217,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' })}
|
||||||
|
{(m.server?.status === 'configured') && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
|
||||||
|
{(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' })}
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export function createAdminRouter({ service, manager, config }) {
|
|||||||
action('server/stop', (v) => manager.stopByOrganizer(v));
|
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||||
action('server/retry', (v) => manager.retry(v));
|
action('server/retry', (v) => manager.retry(v));
|
||||||
action('server/warmup-end', (v) => manager.endWarmup(v));
|
action('server/warmup-end', (v) => manager.endWarmup(v));
|
||||||
|
action('server/pause', (v) => manager.setPaused(v, true));
|
||||||
|
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) => service.startVeto(v));
|
||||||
action('veto/action', (v, b) => service.vetoAction(
|
action('veto/action', (v, b) => service.vetoAction(
|
||||||
|
|||||||
@@ -796,6 +796,8 @@ export class MatchService extends EventEmitter {
|
|||||||
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
||||||
// 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)
|
||||||
|
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
|
|||||||
'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)
|
||||||
'mp_warmup_pausetimer 1',
|
'mp_warmup_pausetimer 1',
|
||||||
|
// Turnierregeln (FACEIT/ESL-Richtwerte): Pausen erlauben, feste Rundenzeiten, Halbzeit
|
||||||
|
'sv_pausable 1',
|
||||||
|
'mp_halftime 1',
|
||||||
|
'mp_freezetime 20',
|
||||||
|
'mp_buytime 20',
|
||||||
|
'mp_startmoney 800',
|
||||||
|
'mp_roundtime 1.92',
|
||||||
...settings.extraCommands,
|
...settings.extraCommands,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -391,6 +391,21 @@ export class ServerManager {
|
|||||||
this.service.changed(viewer.matchId);
|
this.service.changed(viewer.matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin: pausiert oder setzt das laufende Match auf dem Server fort (mp_pause_match / mp_unpause_match). */
|
||||||
|
async setPaused(viewer, paused) {
|
||||||
|
this.service.require(viewer, 'organizer');
|
||||||
|
const row = this.row(viewer.matchId);
|
||||||
|
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit', 'wrong_state');
|
||||||
|
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
|
||||||
|
try {
|
||||||
|
await this.adapter.sendCommands(JSON.parse(row.handle), [paused ? 'mp_pause_match' : 'mp_unpause_match']);
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||||
|
}
|
||||||
|
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. */
|
||||||
startDemoSimulation(matchId) {
|
startDemoSimulation(matchId) {
|
||||||
const match = this.service.getMatch(matchId);
|
const match = this.service.getMatch(matchId);
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
|
|||||||
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
assert.equal(orgView.viewer.role, 'organizer');
|
assert.equal(orgView.viewer.role, 'organizer');
|
||||||
const { codes } = orgView.organizer;
|
const { codes } = orgView.organizer;
|
||||||
|
// Match-Übersicht nur für dieses Match, ohne Zuschauercode
|
||||||
|
assert.match(orgView.organizer.links.overview, new RegExp(`/stream/match/${id}$`));
|
||||||
|
assert.ok(!orgView.organizer.links.overview.includes(codes.spectator));
|
||||||
|
|
||||||
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
|
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
|
||||||
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
|
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
|
||||||
@@ -398,6 +401,7 @@ test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung',
|
|||||||
|
|
||||||
// Ein Match abbrechen wie ein Admin
|
// Ein Match abbrechen wie ein Admin
|
||||||
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
|
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
|
||||||
|
assert.equal((await call('POST', `/admin/matches/${id}/server/pause`, { ...admin, body: {} })).status, 409, 'ohne Server keine Pause');
|
||||||
assert.equal(app.service.getMatch(id).status, 'cancelled');
|
assert.equal(app.service.getMatch(id).status, 'cancelled');
|
||||||
|
|
||||||
// Endgültig löschen
|
// Endgültig löschen
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ test('Konsolentexte können keine Befehle einschleusen', () => {
|
|||||||
assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"'));
|
assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"'));
|
||||||
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
|
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
|
||||||
assert.ok(afterMapChange.includes('mp_maxrounds 16'));
|
assert.ok(afterMapChange.includes('mp_maxrounds 16'));
|
||||||
|
assert.ok(afterMapChange.includes('sv_pausable 1'), 'Pausen-Knopf braucht sv_pausable');
|
||||||
|
assert.ok(afterMapChange.includes('mp_freezetime 20'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => {
|
test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user