Bei Seitenwahl per Messerrunde richtet die App den Match-Server in der Lobby als Messer-Server ein (Passwort des Matches, Aufwärmphase ohne Ende). Spieler sehen die Verbindungsdaten in der Lobby, der Organisator startet bzw. wiederholt die Runde. Nach dem Veto wird derselbe Server fürs Match eingerichtet, die Messer-Einstellungen werden zurückgenommen.
766 lines
41 KiB
JavaScript
766 lines
41 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { loadConfig } from '../src/config.js';
|
|
import { createApp } from '../src/app.js';
|
|
|
|
const quiet = { log() {}, warn() {}, error() {} };
|
|
|
|
async function setup(overrides = {}) {
|
|
const config = loadConfig({
|
|
databaseFile: ':memory:',
|
|
serverAdapter: 'demo',
|
|
createMatchPassword: '',
|
|
requireFullTeams: true,
|
|
idleTimeoutMinutes: 30,
|
|
demo: { startSeconds: 0, failFirstConfigure: false },
|
|
...overrides,
|
|
});
|
|
const app = createApp(config, { logger: quiet });
|
|
await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve));
|
|
const base = `http://127.0.0.1:${app.server.address().port}/api`;
|
|
|
|
const call = async (method, path, { token, playerToken, body } = {}) => {
|
|
const res = await fetch(base + path, {
|
|
method,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
...(playerToken ? { 'x-player-token': playerToken } : {}),
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
return { status: res.status, body: await res.json() };
|
|
};
|
|
return { app, call, close: () => app.close() };
|
|
}
|
|
|
|
async function waitFor(fn, { timeoutMs = 8000, label = 'Bedingung' } = {}) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
for (;;) {
|
|
const value = await fn();
|
|
if (value) return value;
|
|
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung: ${label}`);
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
}
|
|
|
|
/** Erstellt ein Wingman-Match, füllt beide Teams und setzt alle bereit. */
|
|
async function readyWingmanMatch(call, format = 'bo3') {
|
|
const created = await call('POST', '/matches', {
|
|
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format, vetoStart: 'A' },
|
|
});
|
|
assert.equal(created.status, 200, JSON.stringify(created.body));
|
|
const { id, organizerToken: org } = created.body;
|
|
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
const codes = orgView.organizer.codes;
|
|
const tokens = {};
|
|
for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) {
|
|
const res = await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } });
|
|
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
tokens[name] = res.body.playerToken;
|
|
}
|
|
for (const t of Object.values(tokens)) await call('POST', `/matches/${id}/ready`, { token: t, body: { ready: true } });
|
|
return { id, org, codes, tokens };
|
|
}
|
|
|
|
async function playVeto(call, id, captains) {
|
|
for (let guard = 0; guard < 20; guard++) {
|
|
const snap = (await call('GET', `/matches/${id}`, { token: captains.A })).body;
|
|
if (snap.veto.done) return snap;
|
|
const { current } = snap.veto;
|
|
const body = { stepIndex: current.index };
|
|
if (current.type === 'side') body.side = 'CT';
|
|
else body.map = snap.veto.maps.find((m) => m.status === 'available').id;
|
|
const res = await call('POST', `/matches/${id}/veto/action`, { token: captains[current.team], body });
|
|
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
}
|
|
throw new Error('Veto endet nicht');
|
|
}
|
|
|
|
test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', async () => {
|
|
const { app, call, close } = await setup();
|
|
try {
|
|
const cfg = (await call('GET', '/config')).body;
|
|
assert.equal(cfg.server.simulated, true);
|
|
const wingman = cfg.pools.find((p) => p.mode === 'wingman');
|
|
assert.equal(wingman.formats.bo5.supported, false);
|
|
|
|
const unsupported = await call('POST', '/matches', {
|
|
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo5' },
|
|
});
|
|
assert.equal(unsupported.status, 400);
|
|
assert.equal(unsupported.body.code, 'unsupported_format');
|
|
|
|
const created = await call('POST', '/matches', {
|
|
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo3', vetoStart: 'A' },
|
|
});
|
|
const { id, organizerToken: org } = created.body;
|
|
|
|
// Server wird direkt beim Erstellen der Lobby angefordert
|
|
await waitFor(() => app.service.serverRow(id), { label: 'Server-Datensatz' });
|
|
|
|
const publicView = (await call('GET', `/matches/${id}`)).body;
|
|
assert.equal(publicView.viewer.role, 'public');
|
|
assert.equal(publicView.organizer, undefined);
|
|
assert.equal(publicView.veto, null);
|
|
|
|
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
assert.equal(orgView.viewer.role, '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
|
|
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: 'AAAA-AAAA', nickname: 'spy' } })).status, 404);
|
|
|
|
const join = async (team, nickname) => call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname } });
|
|
const a1 = (await join('A', 'a1')).body;
|
|
const a2 = (await join('A', 'a2')).body;
|
|
assert.equal((await join('A', 'a3')).body.code, 'team_full');
|
|
assert.equal((await join('B', 'A1')).body.code, 'name_taken');
|
|
const b1 = (await join('B', 'b1')).body;
|
|
const b2 = (await join('B', 'b2')).body;
|
|
|
|
const a1View = (await call('GET', `/matches/${id}`, { token: a1.playerToken })).body;
|
|
assert.equal(a1View.viewer.role, 'captain');
|
|
assert.equal((await call('GET', `/matches/${id}`, { token: a2.playerToken })).body.viewer.role, 'player');
|
|
|
|
// Veto-Start nur durch Organisator und nur wenn alle bereit sind
|
|
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: a1.playerToken })).status, 403);
|
|
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).body.code, 'teams_not_ready');
|
|
for (const p of [a1, a2, b1, b2]) await call('POST', `/matches/${id}/ready`, { token: p.playerToken, body: { ready: true } });
|
|
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).status, 200);
|
|
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org })).body.code, 'wrong_phase');
|
|
|
|
// Nur der Captain des Teams am Zug darf handeln
|
|
const firstMap = { stepIndex: 0, map: 'de_inferno' };
|
|
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a2.playerToken, body: firstMap })).status, 403);
|
|
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: codes.spectator, body: firstMap })).status, 403);
|
|
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: b1.playerToken, body: firstMap })).body.code, 'not_your_turn');
|
|
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a1.playerToken, body: firstMap })).status, 200);
|
|
// Doppelte Aktion (gleicher Schritt) wird abgelehnt
|
|
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: a1.playerToken, body: firstMap })).body.code, 'stale_step');
|
|
|
|
const done = await playVeto(call, id, { A: a1.playerToken, B: b1.playerToken });
|
|
assert.equal(done.series.length, 3);
|
|
|
|
// Demo-Server wird nach dem Veto automatisch konfiguriert
|
|
await app.manager.poll();
|
|
await waitFor(async () => (await call('GET', `/matches/${id}`, { token: org })).body.status === 'live', { label: 'Match live' });
|
|
|
|
const player = (await call('GET', `/matches/${id}`, { token: a2.playerToken })).body;
|
|
const spectator = (await call('GET', `/matches/${id}`, { token: codes.spectator })).body;
|
|
const organizer = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
assert.equal(player.server.simulated, true);
|
|
assert.ok(player.server.connect);
|
|
assert.equal(player.server.gotv, undefined);
|
|
assert.ok(spectator.server.gotv);
|
|
assert.equal(spectator.server.connect, undefined);
|
|
assert.ok(organizer.server.connect && organizer.server.gotv);
|
|
assert.equal(organizer.series[0].status, 'live');
|
|
|
|
// RCON-Passwort und interne Handles verlassen nie das Backend
|
|
const row = app.service.serverRow(id);
|
|
for (const view of [player, spectator, organizer, publicView]) {
|
|
const json = JSON.stringify(view);
|
|
assert.ok(!json.includes(row.rcon_password));
|
|
assert.ok(!json.includes('handle'));
|
|
}
|
|
|
|
// Nächste Map der Serie laden
|
|
assert.equal((await call('POST', `/matches/${id}/series/next`, { token: a1.playerToken })).status, 403);
|
|
assert.equal((await call('POST', `/matches/${id}/series/next`, { token: org })).status, 200);
|
|
await waitFor(async () => {
|
|
const s = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
return s.status === 'live' && s.currentMapNumber === 2;
|
|
}, { label: 'Map 2' });
|
|
|
|
assert.equal((await call('POST', `/matches/${id}/finish`, { token: org })).status, 200);
|
|
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'Server gestoppt' });
|
|
const list = (await call('GET', '/matches')).body.matches;
|
|
assert.equal(list.find((m) => m.id === id).group, 'closed');
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('fehlgeschlagene Konfiguration kann vom Organisator wiederholt werden', async () => {
|
|
const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: true } });
|
|
try {
|
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo1');
|
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
|
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
|
await app.manager.poll();
|
|
await waitFor(() => app.service.serverRow(id).status === 'config_failed', { label: 'config_failed' });
|
|
|
|
assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: tokens.a1 })).status, 403);
|
|
assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: org })).status, 200);
|
|
await waitFor(() => app.service.serverRow(id).status === 'configured', { label: 'configured' });
|
|
assert.equal(app.service.getMatch(id).status, 'live');
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('ungenutzte Server werden nach der Frist heruntergefahren', async () => {
|
|
const { app, call, close } = await setup({ idleTimeoutMinutes: 10 });
|
|
try {
|
|
const { id } = (await call('POST', '/matches', {
|
|
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'competitive', format: 'bo1' },
|
|
})).body;
|
|
await waitFor(() => app.service.serverRow(id)?.handle, { label: 'Server gestartet' });
|
|
await app.manager.reap(Date.now() + 5 * 60_000);
|
|
assert.notEqual(app.service.serverRow(id).status, 'stopped');
|
|
await app.manager.reap(Date.now() + 11 * 60_000);
|
|
assert.equal(app.service.serverRow(id).status, 'stopped');
|
|
assert.match(app.service.serverRow(id).message, /ungenutzt/);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('Organisator spielt im selben Browser selbst mit (zwei Zugänge)', async () => {
|
|
const { call, close } = await setup({ requireFullTeams: false });
|
|
try {
|
|
const { id, organizerToken: org } = (await call('POST', '/matches', {
|
|
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' },
|
|
})).body;
|
|
const codes = (await call('GET', `/matches/${id}`, { token: org })).body.organizer.codes;
|
|
const me = (await call('POST', `/matches/${id}/join`, { body: { code: codes.A, nickname: 'orga' } })).body;
|
|
const b1 = (await call('POST', `/matches/${id}/join`, { body: { code: codes.B, nickname: 'b1' } })).body;
|
|
|
|
const both = { token: org, playerToken: me.playerToken };
|
|
const view = (await call('GET', `/matches/${id}`, both)).body;
|
|
assert.equal(view.viewer.role, 'organizer');
|
|
assert.equal(view.viewer.isOrganizer, true);
|
|
assert.equal(view.viewer.isCaptain, true);
|
|
assert.equal(view.viewer.team, 'A');
|
|
assert.ok(view.organizer, 'Organisator-Daten bleiben sichtbar');
|
|
|
|
assert.equal((await call('POST', `/matches/${id}/ready`, { ...both, body: { ready: true } })).status, 200);
|
|
await call('POST', `/matches/${id}/ready`, { token: b1.playerToken, body: { ready: true } });
|
|
assert.equal((await call('POST', `/matches/${id}/veto/start`, both)).status, 200);
|
|
|
|
// Nur mit Organisator-Zugang ist er kein Captain und darf nicht bannen
|
|
const ban = { stepIndex: 0, map: 'de_inferno' };
|
|
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: org, body: ban })).status, 403);
|
|
assert.equal((await call('POST', `/matches/${id}/veto/action`, { ...both, body: ban })).status, 200);
|
|
|
|
// Ein fremder Spieler-Token macht niemanden zum Organisator
|
|
const fake = (await call('GET', `/matches/${id}`, { token: b1.playerToken, playerToken: me.playerToken })).body;
|
|
assert.equal(fake.viewer.isOrganizer, false);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('Live-Ergebnisse aus Serverlogs und Demo-Simulation', async () => {
|
|
const { app, call, close } = await setup({ demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 0 } });
|
|
try {
|
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
|
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
|
await app.manager.poll();
|
|
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
|
|
|
const { config_key: key } = app.service.serverRow(id);
|
|
const map1 = app.service.seriesMaps(id)[0];
|
|
const base = `http://127.0.0.1:${app.server.address().port}/api/logs/${id}`;
|
|
const post = (k, text) => fetch(`${base}/${k}`, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: text });
|
|
|
|
assert.equal((await post('falsch', 'x')).status, 403);
|
|
|
|
const ct = map1.ct_team === 'A' ? 'Alpha' : 'Bravo';
|
|
const t = map1.ct_team === 'A' ? 'Bravo' : 'Alpha';
|
|
const res = await post(key, [
|
|
`L 10/25/2026 - 18:00:00: MatchStatus: Team playing "CT": ${ct}`,
|
|
`L 10/25/2026 - 18:00:00: MatchStatus: Team playing "TERRORIST": ${t}`,
|
|
`L 10/25/2026 - 18:00:01: World triggered "Match_Start" on "${map1.map}"`,
|
|
'L 10/25/2026 - 18:01:00: Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")',
|
|
'L 10/25/2026 - 18:02:00: Team "TERRORIST" triggered "SFUI_Notice_Target_Bombed" (CT "1") (T "1")',
|
|
].join('\n'));
|
|
assert.equal(res.status, 200);
|
|
|
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
const ctTeam = map1.ct_team;
|
|
assert.equal(snap.series[0].score[ctTeam], 1);
|
|
assert.equal(snap.series[0].live.rounds.length, 2);
|
|
assert.equal(snap.series[0].live.rounds[1].reason, 'bomb');
|
|
|
|
await post(key, `Game Over: wingman mg_active ${map1.map} score 1:1 after 3 min`);
|
|
// Gleichstand beendet die Map ohne Sieger; Serienstand bleibt 0:0
|
|
const after = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
assert.equal(after.series[0].status, 'done');
|
|
assert.deepEqual(after.seriesScore, { A: 0, B: 0 });
|
|
|
|
// Map 2 laden und den Demo-Verlauf bis zum Ende laufen lassen
|
|
await call('POST', `/matches/${id}/series/next`, { token: org });
|
|
await waitFor(() => app.service.serverRow(id).status === 'configured' && app.service.getMatch(id).current_map_number === 2, { label: 'Map 2' });
|
|
assert.equal((await call('POST', `/matches/${id}/server/warmup-end`, { token: org })).status, 200);
|
|
await waitFor(() => app.service.seriesMaps(id)[1].status === 'done', { label: 'Demo-Map beendet', timeoutMs: 15000 });
|
|
const final = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
const winner = final.series[1].winner;
|
|
assert.ok(['A', 'B'].includes(winner));
|
|
assert.equal(final.series[1].score[winner], 9, 'Wingman: 9 Runden zum Sieg');
|
|
assert.equal(final.seriesScore[winner], 1);
|
|
const list = (await call('GET', '/matches')).body.matches.find((m) => m.id === id);
|
|
assert.equal(list.seriesScore[winner], 1);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('Nach Map-Ende lädt die nächste Map automatisch, entschiedene Serie wird abgeschlossen', async () => {
|
|
const { app, call, close } = await setup({ autoAdvanceSeconds: 0 });
|
|
try {
|
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
|
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
|
await app.manager.poll();
|
|
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
|
|
|
const key = app.service.serverRow(id).config_key;
|
|
const winMapForAlpha = async (map) => {
|
|
const body = [
|
|
'Team playing "CT": Alpha',
|
|
'Team playing "TERRORIST": Bravo',
|
|
'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');
|
|
const res = await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body });
|
|
assert.equal(res.status, 200);
|
|
};
|
|
|
|
await winMapForAlpha(app.service.seriesMaps(id)[0].map);
|
|
// Serie 1:0, nicht entschieden -> Map 2 wird automatisch geladen
|
|
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');
|
|
|
|
await winMapForAlpha(app.service.seriesMaps(id)[1].map);
|
|
// 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.serverRow(id).status === 'stopped', { label: 'Server freigegeben' });
|
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
assert.deepEqual(snap.seriesScore, { A: 2, B: 0 });
|
|
assert.equal(snap.series[2].winner, null);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('Stream-Übersicht aller Matches: Inhalte, keine Geheimnisse, optionaler Schlüssel', async () => {
|
|
const { app, call, close } = await setup({ streamKey: 'geheim123' });
|
|
try {
|
|
const lobby = (await call('POST', '/matches', { body: { teamA: 'Lobby A', teamB: 'Lobby B', mode: 'wingman', format: 'bo1' } })).body;
|
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
|
await call('POST', `/matches/${id}/veto/action`, { token: tokens.a1, body: { stepIndex: 0, map: 'de_inferno' } });
|
|
|
|
assert.equal((await call('GET', '/stream/overview')).status, 403);
|
|
assert.equal((await call('GET', '/stream/overview?key=falsch')).status, 403);
|
|
const res = await call('GET', '/stream/overview?key=geheim123');
|
|
assert.equal(res.status, 200);
|
|
const { active, upcoming } = res.body;
|
|
assert.equal(active.length, 1);
|
|
assert.equal(active[0].status, 'veto');
|
|
assert.equal(active[0].veto.maps.find((m) => m.id === 'de_inferno').status, 'banned');
|
|
assert.equal(active[0].veto.current.team, 'B');
|
|
assert.ok(upcoming.some((m) => m.id === lobby.id));
|
|
|
|
const json = JSON.stringify(res.body);
|
|
const row = app.service.getMatch(id);
|
|
for (const secret of [row.code_a, row.code_b, row.code_spectator, app.service.serverRow(id).rcon_password, app.service.serverRow(id).server_password]) {
|
|
assert.ok(!json.includes(secret), 'keine Codes oder Passwörter in der Stream-Übersicht');
|
|
}
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung', async () => {
|
|
const { app, call, close } = await setup({ adminPassword: 'Admin123' });
|
|
try {
|
|
const { id } = (await call('POST', '/matches', {
|
|
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1' },
|
|
})).body;
|
|
const adminListed = (await call('GET', '/admin/matches')).body; // ohne Anmeldung
|
|
assert.equal(adminListed.error, 'Admin-Anmeldung erforderlich');
|
|
|
|
assert.equal((await call('POST', '/admin/login', { body: { password: 'falsch' } })).status, 401);
|
|
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
|
|
const admin = { token: login.token };
|
|
assert.ok(login.token);
|
|
|
|
const list = (await call('GET', '/admin/matches', admin)).body.matches;
|
|
const mine = list.find((m) => m.id === id);
|
|
assert.ok(mine, 'alle Matches sichtbar, nicht nur die eigenen');
|
|
assert.ok(mine.organizer.codes.A, 'Codes sichtbar');
|
|
|
|
// 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}/server/pause`, { ...admin, body: {} })).status, 409, 'ohne Server keine Pause');
|
|
assert.equal(app.service.getMatch(id).status, 'cancelled');
|
|
|
|
// Endgültig löschen
|
|
assert.equal((await call('DELETE', `/admin/matches/${id}`, admin)).status, 200);
|
|
assert.equal((await call('GET', `/matches/${id}`)).status, 404);
|
|
|
|
// Abmelden: Token ist danach ungültig
|
|
await call('POST', '/admin/logout', admin);
|
|
assert.equal((await call('GET', '/admin/session', admin)).status, 401);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('Rollback: nur mit Admin-Anmeldung, nur für die laufende Map, Dateiname wird geprüft', async () => {
|
|
const { app, call, close } = await setup({ adminPassword: 'Admin123', demo: { startSeconds: 0, failFirstConfigure: false, roundSeconds: 600 } });
|
|
try {
|
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo3');
|
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
|
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
|
await app.manager.poll();
|
|
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
|
await call('POST', `/matches/${id}/server/warmup-end`, { token: org });
|
|
|
|
// Organisator sieht keine Rollback-Funktion
|
|
assert.equal((await call('GET', `/admin/matches/${id}/rollback-points`, { token: org })).status, 401);
|
|
assert.equal((await call('POST', `/matches/${id}/rollback`, { token: org, body: { file: 'x.txt' } })).status, 404);
|
|
|
|
const login = (await call('POST', '/admin/login', { body: { password: 'Admin123' } })).body;
|
|
const admin = { token: login.token };
|
|
const points = (await call('GET', `/admin/matches/${id}/rollback-points`, admin)).body.points;
|
|
assert.equal(points.length, 3, 'Demo liefert drei Sicherungen der aktuellen Map');
|
|
const map = app.service.seriesMaps(id)[0].map;
|
|
assert.ok(points.every((p) => p.map === map));
|
|
|
|
// Dateien, die nicht zu diesem Match gehören, werden abgelehnt
|
|
assert.equal((await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: '../../x.txt' } })).status, 400);
|
|
|
|
const ok = await call('POST', `/admin/matches/${id}/rollback`, { ...admin, body: { file: points[0].file } });
|
|
assert.equal(ok.status, 200);
|
|
const log = app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND type = 'rollback'").all(id);
|
|
assert.equal(log.length, 1);
|
|
assert.match(log[0].message, /zurückgespielt/);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('Admin-Modus ohne ADMIN_PASSWORD ist komplett aus', async () => {
|
|
const { call, close } = await setup({ adminPassword: '' });
|
|
try {
|
|
const res = await call('POST', '/admin/login', { body: { password: 'x' } });
|
|
assert.equal(res.status, 404);
|
|
assert.equal(res.body.code, 'admin_disabled');
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
|
const { call, close } = await setup();
|
|
try {
|
|
const { id, org, tokens } = await readyWingmanMatch(call);
|
|
assert.equal((await call('POST', `/matches/${id}/leave`, { token: tokens.a1 })).status, 200);
|
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
const teamA = snap.teams.A.players;
|
|
assert.equal(teamA.length, 1);
|
|
assert.equal(teamA[0].nickname, 'a2');
|
|
assert.equal(teamA[0].isCaptain, true);
|
|
// Alter Token ist ungültig
|
|
assert.equal((await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.viewer.role, 'public');
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
test('Neustart im laufenden Match: Map wird wieder geladen, Server läuft die volle Frist', async () => {
|
|
const { app, call, close } = await setup({ idleTimeoutMinutes: 30 });
|
|
try {
|
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo1');
|
|
await call('POST', `/matches/${id}/veto/start`, { token: org });
|
|
await playVeto(call, id, { A: tokens.a1, B: tokens.b1 });
|
|
await app.manager.poll();
|
|
await waitFor(() => app.service.getMatch(id).status === 'live', { label: 'live' });
|
|
|
|
// Lange nichts los, dann stoppt der Server
|
|
const hourAgo = Date.now() - 60 * 60_000;
|
|
app.db.prepare('UPDATE matches SET last_activity_at = ? WHERE id = ?').run(hourAgo, id);
|
|
await call('POST', `/matches/${id}/server/stop`, { token: org });
|
|
await waitFor(() => app.service.serverRow(id).status === 'stopped', { label: 'gestoppt' });
|
|
|
|
// Neustart durch den Organisator: Server fährt hoch und lädt die Map wieder (Match ist weiter "live")
|
|
const res = await call('POST', `/matches/${id}/server/retry`, { token: org });
|
|
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
await waitFor(async () => { await app.manager.poll(); return app.service.serverRow(id).status === 'configured'; }, { label: 'neu konfiguriert' });
|
|
assert.equal(app.service.getMatch(id).status, 'live');
|
|
|
|
// Die Abschaltung darf den frisch gestarteten Server nicht sofort wieder stoppen
|
|
await app.manager.reap();
|
|
assert.equal(app.service.serverRow(id).status, 'configured');
|
|
// Nach Ablauf der Frist ab dem Neustart dagegen schon
|
|
await app.manager.reap(Date.now() + 31 * 60_000);
|
|
assert.equal(app.service.serverRow(id).status, 'stopped');
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
test('Archiv: nur abgeschlossene Matches, eigene Liste, automatisch nach Frist', async () => {
|
|
const { app, call, close } = await setup({ adminPassword: 'admin-geheim', archiveAfterDays: 3 });
|
|
try {
|
|
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
|
const mk = async (a, b) => (await call('POST', '/matches', { body: { teamA: a, teamB: b, mode: 'wingman', format: 'bo1' } })).body;
|
|
const open = await mk('Offen A', 'Offen B');
|
|
const done = await mk('Fertig A', 'Fertig B');
|
|
const old = await mk('Alt A', 'Alt B');
|
|
|
|
// Laufende Matches lassen sich nicht archivieren
|
|
assert.equal((await call('POST', `/admin/matches/${open.id}/archive`, { token })).status, 409);
|
|
|
|
await call('POST', `/admin/matches/${done.id}/finish`, { token });
|
|
assert.equal((await call('POST', `/admin/matches/${done.id}/archive`, { token })).status, 200);
|
|
let list = (await call('GET', '/admin/matches', { token })).body;
|
|
assert.ok(!list.matches.some((m) => m.id === done.id), 'archiviertes Match fehlt in der normalen Liste');
|
|
assert.equal(list.archivedCount, 1);
|
|
const archive = (await call('GET', '/admin/matches?archived=1', { token })).body;
|
|
assert.deepEqual(archive.matches.map((m) => m.id), [done.id]);
|
|
assert.ok(archive.matches[0].archivedAt > 0);
|
|
|
|
// Zurückholen
|
|
await call('POST', `/admin/matches/${done.id}/unarchive`, { token });
|
|
list = (await call('GET', '/admin/matches', { token })).body;
|
|
assert.ok(list.matches.some((m) => m.id === done.id));
|
|
|
|
// Automatisch: abgebrochenes Match, älter als 3 Tage
|
|
await call('POST', `/admin/matches/${old.id}/cancel`, { token });
|
|
assert.equal(app.service.archiveClosed(Date.now() + 2 * 86_400_000), 0, 'nach 2 Tagen noch nicht');
|
|
await app.manager.reap(Date.now() + 4 * 86_400_000);
|
|
assert.ok(app.service.getMatch(old.id).archived_at > 0, 'nach 4 Tagen archiviert');
|
|
assert.ok(app.service.getMatch(done.id).archived_at > 0, 'zurückgeholtes Match wird nach Ablauf der Frist wieder archiviert');
|
|
assert.equal(app.service.getMatch(open.id).archived_at, null, 'offene Matches bleiben');
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
test('Messer-Server: nur Admin startet, Startseite sieht laufende, Ports zählen bei der Vergabe mit', async () => {
|
|
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
|
|
try {
|
|
assert.equal((await call('POST', '/admin/knife', { body: {} })).status, 401);
|
|
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
|
const info = (await call('GET', '/admin/knife', { token })).body;
|
|
assert.deepEqual(info.modes.map((m) => m.id).sort(), ['competitive', 'wingman']);
|
|
const created = await call('POST', '/admin/knife', { token, body: { mode: 'competitive', map: 'de_inferno' } });
|
|
assert.equal(created.status, 200, JSON.stringify(created.body));
|
|
|
|
// Noch nicht bereit: Startseite zeigt ihn nicht
|
|
assert.equal((await call('GET', '/knife-servers')).body.servers.length, 0);
|
|
await app.knife.poll();
|
|
await waitFor(() => app.knife.row(created.body.id).status === 'running', { label: 'Messer-Server bereit' });
|
|
const pub = (await call('GET', '/knife-servers')).body.servers;
|
|
assert.equal(pub.length, 1);
|
|
assert.equal(pub[0].map, 'de_inferno');
|
|
assert.ok(!JSON.stringify(pub).includes(app.knife.row(created.body.id).rcon_password));
|
|
|
|
// Ports des Messer-Servers sind für Matches belegt
|
|
const row = app.knife.row(created.body.id);
|
|
assert.ok(app.manager.usedPortsOn(row.host).has(row.port));
|
|
|
|
assert.equal((await call('DELETE', `/admin/knife/${created.body.id}`, { token })).status, 200);
|
|
assert.equal(app.knife.list().length, 0);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
test('Map-Pools im Admin: Auswahl gilt für neue Matches, Veto passt sich an, laufende Matches behalten ihre Maps', async () => {
|
|
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
|
|
try {
|
|
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
|
|
const before = (await call('POST', '/matches', { body: { teamA: 'Vorher A', teamB: 'Vorher B', mode: 'competitive', format: 'bo3' } })).body;
|
|
const view = (await call('GET', '/admin/map-pools', { token })).body;
|
|
const comp = view.pools.find((p) => p.mode === 'competitive');
|
|
assert.ok(['de_overpass', 'de_vertigo', 'de_cache'].every((id) => comp.maps.some((m) => m.id === id && m.active)));
|
|
|
|
// Nur 5 Maps: BO5 geht noch, BO3 hat dann 2 Bans
|
|
const five = ['de_cache', 'de_dust2', 'de_inferno', 'de_mirage', 'de_nuke'];
|
|
const saved = await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: five } });
|
|
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
|
const pool = saved.body.pools.find((p) => p.mode === 'competitive');
|
|
assert.equal(pool.maps.filter((m) => m.active).length, 5);
|
|
assert.deepEqual(pool.formats.bo3.flow, ['ban:X', 'ban:Y', 'pick:X', 'side:Y', 'pick:Y', 'side:X', 'decider', 'side:X']);
|
|
|
|
// Neue Matches bekommen die neue Auswahl, das alte behält seine 10 Maps
|
|
const after = (await call('POST', '/matches', { body: { teamA: 'Nachher A', teamB: 'Nachher B', mode: 'competitive', format: 'bo3' } })).body;
|
|
assert.equal(JSON.parse(app.service.getMatch(after.id).maps).length, 5);
|
|
assert.equal(JSON.parse(app.service.getMatch(before.id).maps).length, 10);
|
|
// Öffentliche Konfiguration (Match erstellen) zeigt die neuen Formate
|
|
const cfg = (await call('GET', '/config')).body;
|
|
assert.equal(cfg.pools.find((p) => p.mode === 'competitive').maps.length, 5);
|
|
|
|
// Ungültig: zu wenige oder unbekannte Maps
|
|
assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache'] } })).status, 400);
|
|
assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache', 'de_foo'] } })).status, 400);
|
|
assert.equal((await call('PUT', '/admin/map-pools', { body: { mode: 'competitive', maps: five } })).status, 401);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
test('Seitenwahl per Messerrunde: Organisator legt fest, wer das Veto beginnt', async () => {
|
|
const { app, call, close } = await setup();
|
|
try {
|
|
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo3', vetoStart: 'knife' } });
|
|
assert.equal(created.status, 200, JSON.stringify(created.body));
|
|
const { id, organizerToken: org } = created.body;
|
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
assert.equal(snap.vetoStart, 'knife');
|
|
const codes = snap.organizer.codes;
|
|
for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) {
|
|
const r = await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } });
|
|
await call('POST', `/matches/${id}/ready`, { token: r.body.playerToken, body: { ready: true } });
|
|
}
|
|
// Ohne Angabe, wer beginnt, startet das Veto nicht
|
|
const missing = await call('POST', `/matches/${id}/veto/start`, { token: org });
|
|
assert.equal(missing.status, 400);
|
|
assert.equal(missing.body.code, 'first_team_required');
|
|
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'X' } })).status, 400);
|
|
|
|
const ok = await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'B' } });
|
|
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
|
const after = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
assert.equal(after.status, 'veto');
|
|
assert.equal(after.veto.current.team, 'B', 'das gewählte Team beginnt');
|
|
assert.ok(app.service.db.prepare("SELECT message FROM events WHERE match_id = ? AND message LIKE '%Messerrunde%'").get(id));
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
test('Ohne Messerrunde bleibt firstTeam wirkungslos', async () => {
|
|
const { call, close } = await setup();
|
|
try {
|
|
const { id, org, tokens } = await readyWingmanMatch(call, 'bo1');
|
|
await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'B' } });
|
|
const snap = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body;
|
|
assert.equal(snap.veto.current.team, 'A', 'feste Startregel A gilt');
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
test('Chat: Match-Chat für beide Teams und Organisator, Team-Chat nur fürs eigene Team, Zuschauer sehen nichts', async () => {
|
|
const { call, close } = await setup();
|
|
try {
|
|
const { id, org, codes, tokens } = await readyWingmanMatch(call, 'bo1');
|
|
const spectator = codes.spectator;
|
|
const post = (token, channel, text) => call('POST', `/matches/${id}/chat`, { token, body: { channel, text } });
|
|
|
|
assert.equal((await post(tokens.a1, 'match', 'Hallo zusammen')).status, 200);
|
|
assert.equal((await post(tokens.a1, 'A', 'Wir bannen Nuke')).status, 200);
|
|
assert.equal((await post(org, 'match', 'Veto startet gleich')).status, 200);
|
|
// Fremder Team-Chat und Zuschauer: verboten
|
|
assert.equal((await post(tokens.b1, 'A', 'Spioniere')).status, 403);
|
|
assert.equal((await post(org, 'A', 'Organisator im Team-Chat')).status, 403);
|
|
assert.equal((await post(spectator, 'match', 'Zuschauer')).status, 403);
|
|
assert.equal((await post(tokens.a1, 'match', ' ')).status, 400);
|
|
assert.equal((await post(tokens.a1, 'match', 'x'.repeat(301))).status, 400);
|
|
|
|
const a = (await call('GET', `/matches/${id}`, { token: tokens.a2 })).body.chat;
|
|
assert.deepEqual(a.channels.map((c) => c.key), ['match', 'A']);
|
|
assert.deepEqual(a.messages.match.map((m) => m.text), ['Hallo zusammen', 'Veto startet gleich']);
|
|
assert.equal(a.messages.match[1].organizer, true);
|
|
assert.deepEqual(a.messages.A.map((m) => m.text), ['Wir bannen Nuke']);
|
|
|
|
const b = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.chat;
|
|
assert.deepEqual(b.channels.map((c) => c.key), ['match', 'B']);
|
|
assert.ok(!JSON.stringify(b).includes('Wir bannen Nuke'), 'Team B sieht den Team-Chat von A nicht');
|
|
assert.deepEqual((await call('GET', `/matches/${id}`, { token: org })).body.chat.channels.map((c) => c.key), ['match']);
|
|
assert.equal((await call('GET', `/matches/${id}`, { token: spectator })).body.chat, null);
|
|
assert.equal((await call('GET', `/matches/${id}`)).body.chat, null);
|
|
|
|
// Spam-Schutz und nach Matchende nur lesbar
|
|
for (let i = 0; i < 5; i++) await post(tokens.b1, 'match', `Nachricht ${i}`);
|
|
assert.equal((await post(tokens.b1, 'match', 'eine zu viel')).status, 429);
|
|
await call('POST', `/matches/${id}/finish`, { token: org });
|
|
assert.equal((await post(tokens.a1, 'match', 'Nach dem Ende')).status, 409);
|
|
const closed = (await call('GET', `/matches/${id}`, { token: tokens.a1 })).body.chat;
|
|
assert.equal(closed.canWrite, false);
|
|
assert.equal(closed.messages.match.length, 7);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|
|
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' });
|
|
assert.equal(player.server.connect.password, app.manager.row(id).server_password, 'Spieler bekommen das Passwort des Matches');
|
|
const pub = (await call('GET', `/matches/${id}`)).body;
|
|
assert.equal(pub.server?.connect, undefined, 'öffentlich keine Verbindungsdaten');
|
|
|
|
// Nur der Organisator startet die Runde
|
|
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: tokens.a1 })).status, 403);
|
|
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 200);
|
|
assert.equal(app.manager.row(id).knife_phase, 'live');
|
|
assert.equal((await call('POST', `/matches/${id}/server/knife-reset`, { token: org })).status, 200);
|
|
assert.equal(app.manager.row(id).knife_phase, 'warmup');
|
|
|
|
// Nach dem Veto wird derselbe Server fürs Match eingerichtet, die Messer-Einstellungen werden zurückgenommen
|
|
const handle = app.manager.row(id).handle;
|
|
const plans = [];
|
|
const configure = app.adapter.configure.bind(app.adapter);
|
|
app.adapter.configure = async (h, plan) => { plans.push(plan); return configure(h, plan); };
|
|
for (const t of Object.values(tokens)) await call('POST', `/matches/${id}/ready`, { token: t, body: { ready: true } });
|
|
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'A' } })).status, 200);
|
|
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
|
const capA = snap.teams.A.players.find((p) => p.isCaptain)?.nickname ?? 'a1';
|
|
const capB = snap.teams.B.players.find((p) => p.isCaptain)?.nickname ?? 'b1';
|
|
await playVeto(call, id, { A: tokens[capA], B: tokens[capB] });
|
|
await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Match eingerichtet' });
|
|
const row = app.manager.row(id);
|
|
assert.equal(row.handle, handle, 'derselbe Server');
|
|
assert.equal(row.knife_at, null);
|
|
assert.ok(plans.at(-1).afterMapChange.includes('mp_give_player_c4 1'));
|
|
assert.equal((await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.server.knife, undefined);
|
|
} finally {
|
|
await close();
|
|
}
|
|
});
|