Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter
Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
104aa1a67b
27 files changed
+6355
No files matched your search
@@ -0,0 +1,235 @@
|
||||
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, body } = {}) => {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
};
|
||||
return { app, 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;
|
||||
|
||||
// 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('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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import { decodePackets, encodePacket, parseStatus, runRcon } from '../src/servers/rcon.js';
|
||||
import { buildMapCommands, sanitizeConsoleText } from '../src/servers/cs2Config.js';
|
||||
|
||||
/** Minimaler Source-RCON-Server für Tests. */
|
||||
function fakeRconServer(password, onCommand) {
|
||||
const server = net.createServer((socket) => {
|
||||
let buffer = Buffer.alloc(0);
|
||||
socket.on('data', (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
const { packets, rest } = decodePackets(buffer);
|
||||
buffer = rest;
|
||||
for (const p of packets) {
|
||||
if (p.type === 3) {
|
||||
socket.write(encodePacket(p.body === password ? p.id : -1, 2, ''));
|
||||
} else {
|
||||
socket.write(encodePacket(p.id, 0, onCommand(p.body)));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)));
|
||||
}
|
||||
|
||||
test('Paket-Kodierung ist umkehrbar', () => {
|
||||
const buf = Buffer.concat([encodePacket(7, 2, 'status'), encodePacket(8, 2, 'mp_warmup_end')]);
|
||||
const { packets, rest } = decodePackets(buf);
|
||||
assert.deepEqual(packets.map((p) => [p.id, p.body]), [[7, 'status'], [8, 'mp_warmup_end']]);
|
||||
assert.equal(rest.length, 0);
|
||||
const partial = decodePackets(buf.subarray(0, 10));
|
||||
assert.equal(partial.packets.length, 0);
|
||||
});
|
||||
|
||||
test('RCON-Client authentifiziert und führt Befehle aus', async () => {
|
||||
const seen = [];
|
||||
const server = await fakeRconServer('geheim', (cmd) => {
|
||||
seen.push(cmd);
|
||||
return cmd === 'status' ? 'map : de_mirage\nplayers : 3 humans, 0 bots (10 max)' : '';
|
||||
});
|
||||
const { port } = server.address();
|
||||
try {
|
||||
const [out] = await runRcon({ host: '127.0.0.1', port, password: 'geheim' }, ['status', 'mp_warmup_end']);
|
||||
assert.deepEqual(parseStatus(out), { map: 'de_mirage', playerCount: 3 });
|
||||
assert.deepEqual(seen, ['status', 'mp_warmup_end']);
|
||||
await assert.rejects(runRcon({ host: '127.0.0.1', port, password: 'falsch' }, ['status']), /abgelehnt/);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Konsolentexte können keine Befehle einschleusen', () => {
|
||||
assert.equal(sanitizeConsoleText('Team"; rcon_password x'), 'Team rcon_password x');
|
||||
const { beforeMapChange, afterMapChange } = buildMapCommands({
|
||||
match: { team_a_name: 'Alpha";quit', team_b_name: 'Bravo' },
|
||||
pool: { mode: 'wingman', gameType: 0, gameMode: 2 },
|
||||
seriesMap: { map: 'de_nuke', ctTeam: 'B' },
|
||||
secrets: { serverPassword: 'abc', tvPassword: 'def' },
|
||||
settings: { tvEnabled: true, tvDelaySeconds: 90, maxRoundsWingman: 16, maxRoundsCompetitive: 24, overtime: true, extraCommands: [] },
|
||||
});
|
||||
assert.ok(beforeMapChange.includes('game_mode 2'));
|
||||
assert.equal(beforeMapChange.at(-1), 'changelevel de_nuke');
|
||||
assert.ok(afterMapChange.includes('mp_teamname_1 "Bravo"'));
|
||||
assert.ok(afterMapChange.includes('mp_teamname_2 "Alphaquit"'));
|
||||
assert.ok(afterMapChange.includes('mp_maxrounds 16'));
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { computeVetoState, resolveAction, validateFlow } from '../src/veto.js';
|
||||
import { buildMapPools, loadMapPools } from '../src/mapPools.js';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pools = loadMapPools(path.join(here, '..', 'config', 'map-pools.json'));
|
||||
|
||||
test('alle ausgelieferten Abläufe sind gültig, Wingman-BO5 ist nicht unterstützt', () => {
|
||||
assert.equal(pools.competitive.formats.bo1.supported, true);
|
||||
assert.equal(pools.competitive.formats.bo3.supported, true);
|
||||
assert.equal(pools.competitive.formats.bo5.supported, true);
|
||||
assert.equal(pools.wingman.formats.bo1.supported, true);
|
||||
assert.equal(pools.wingman.formats.bo3.supported, true);
|
||||
assert.equal(pools.wingman.formats.bo5.supported, false);
|
||||
});
|
||||
|
||||
test('validateFlow erkennt unpassende Abläufe', () => {
|
||||
assert.deepEqual(validateFlow(['ban:X', 'ban:Y', 'decider'], 3, 'bo1'), []);
|
||||
assert.ok(validateFlow(['ban:X', 'decider'], 3, 'bo1').length > 0, 'zu wenige Bans');
|
||||
assert.ok(validateFlow(['ban:X', 'ban:Y', 'decider'], 3, 'bo3').length > 0, 'fehlende Picks');
|
||||
assert.ok(validateFlow(['side:X', 'ban:Y', 'ban:X', 'decider'], 3, 'bo1').length > 0, 'Seitenwahl ohne Pick');
|
||||
assert.ok(validateFlow(['ban:X', 'decider', 'ban:Y'], 3, 'bo1').length > 0, 'Ban nach Entscheidungsmap');
|
||||
assert.ok(validateFlow(['foo:X'], 1, 'bo1').length > 0, 'unbekannter Schritt');
|
||||
});
|
||||
|
||||
test('ungültiger Ablauf in der Konfiguration deaktiviert nur dieses Format', () => {
|
||||
const custom = buildMapPools({
|
||||
pools: {
|
||||
mini: {
|
||||
label: 'Mini', teamSize: 2, gameType: 0, gameMode: 2,
|
||||
maps: [{ id: 'de_a', name: 'A' }, { id: 'de_b', name: 'B' }, { id: 'de_c', name: 'C' }],
|
||||
flows: { bo1: ['ban:X', 'ban:Y', 'decider'], bo3: ['pick:X', 'decider'] },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(custom.mini.formats.bo1.supported, true);
|
||||
assert.equal(custom.mini.formats.bo3.supported, false);
|
||||
assert.match(custom.mini.formats.bo5.reason, /Kein Veto-Ablauf/);
|
||||
});
|
||||
|
||||
function play(pool, format, firstTeam, choices) {
|
||||
const ctx = { maps: pool.maps, flow: pool.formats[format].flow, firstTeam, actions: [] };
|
||||
for (const choice of choices) {
|
||||
const state = computeVetoState(ctx);
|
||||
const added = resolveAction(ctx, { team: state.current.team, stepIndex: state.current.index, ...choice });
|
||||
ctx.actions = [...ctx.actions, ...added];
|
||||
}
|
||||
return computeVetoState(ctx);
|
||||
}
|
||||
|
||||
test('BO3 Competitive: kompletter Ablauf mit Seitenwahl und Entscheidungsmap', () => {
|
||||
const state = play(pools.competitive, 'bo3', 'B', [
|
||||
{ map: 'de_train' }, // B bannt
|
||||
{ map: 'de_nuke' }, // A bannt
|
||||
{ map: 'de_mirage' }, // B pickt
|
||||
{ side: 'CT' }, // A wählt Seite auf Mirage
|
||||
{ map: 'de_inferno' }, // A pickt
|
||||
{ side: 'T' }, // B wählt Seite auf Inferno
|
||||
{ map: 'de_dust2' }, // B bannt
|
||||
{ map: 'de_anubis' }, // A bannt -> Ancient bleibt
|
||||
{ side: 'CT' }, // B (X) wählt Seite auf der Entscheidungsmap
|
||||
]);
|
||||
assert.equal(state.done, true);
|
||||
assert.deepEqual(state.series.map((s) => s.map), ['de_mirage', 'de_inferno', 'de_ancient']);
|
||||
assert.equal(state.series[0].pickedBy, 'B');
|
||||
assert.deepEqual(state.series[0].startSides, { A: 'CT', B: 'T' });
|
||||
assert.deepEqual(state.series[1].startSides, { B: 'T', A: 'CT' });
|
||||
assert.equal(state.series[2].pickedBy, null);
|
||||
assert.deepEqual(state.series[2].startSides, { B: 'CT', A: 'T' });
|
||||
assert.equal(state.maps.find((m) => m.id === 'de_ancient').status, 'decider');
|
||||
});
|
||||
|
||||
test('BO1 Wingman: Entscheidungsmap wird automatisch übernommen', () => {
|
||||
const state = play(pools.wingman, 'bo1', 'A', [{ map: 'de_inferno' }, { map: 'de_nuke' }, { map: 'de_overpass' }]);
|
||||
assert.equal(state.done, false);
|
||||
assert.equal(state.current.type, 'side');
|
||||
assert.equal(state.current.team, 'B');
|
||||
assert.equal(state.current.map, 'de_vertigo');
|
||||
});
|
||||
|
||||
test('falsches Team, veraltete Schritte und vergebene Maps werden abgelehnt', () => {
|
||||
const pool = pools.competitive;
|
||||
const ctx = { maps: pool.maps, flow: pool.formats.bo1.flow, firstTeam: 'A', actions: [] };
|
||||
assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 0, map: 'de_nuke' }), { code: 'not_your_turn' });
|
||||
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 1, map: 'de_nuke' }), { code: 'stale_step' });
|
||||
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 0, map: 'de_cache' }), { code: 'invalid_map' });
|
||||
ctx.actions = resolveAction(ctx, { team: 'A', stepIndex: 0, map: 'de_nuke' });
|
||||
assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 1, map: 'de_nuke' }), { code: 'map_unavailable' });
|
||||
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 0, map: 'de_train' }), { code: 'stale_step' });
|
||||
});
|
||||
Reference in new issue
Block a user