Merge feature/messer-match-server in main
# Conflicts: # client/src/styles.css
This commit is contained in:
commit
148cd99856
10 files changed
+220
-20
No files matched your search
@@ -165,6 +165,9 @@ function migrate(db) {
|
||||
}
|
||||
const serverColumns = columns('servers');
|
||||
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
||||
// Seitenwahl per Messerrunde auf dem Match-Server: wann sie eingerichtet wurde und was gerade läuft
|
||||
if (!serverColumns.has('knife_at')) db.exec('ALTER TABLE servers ADD COLUMN knife_at INTEGER');
|
||||
if (!serverColumns.has('knife_phase')) db.exec('ALTER TABLE servers ADD COLUMN knife_phase TEXT');
|
||||
// Abgeschlossene Matches lassen sich archivieren; sie verschwinden dann aus der normalen Admin-Liste
|
||||
if (!columns('matches').has('archived_at')) db.exec('ALTER TABLE matches ADD COLUMN archived_at INTEGER');
|
||||
if (!columns('knife_servers').has('mode')) db.exec("ALTER TABLE knife_servers ADD COLUMN mode TEXT NOT NULL DEFAULT 'competitive'");
|
||||
|
||||
@@ -123,6 +123,8 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
|
||||
action('server/retry', (v) => manager.retry(v));
|
||||
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||
action('server/warmup-end', (v) => manager.endWarmup(v));
|
||||
action('server/knife-start', (v) => manager.knifeAction(v, 'start'));
|
||||
action('server/knife-reset', (v) => manager.knifeAction(v, 'reset'));
|
||||
action('series/next', (v) => manager.loadNextMap(v));
|
||||
action('finish', (v) => service.close(v, 'finished'));
|
||||
action('cancel', (v) => service.close(v, 'cancelled'));
|
||||
|
||||
@@ -1065,25 +1065,21 @@ export class MatchService extends EventEmitter {
|
||||
updatedAt: row.updated_at,
|
||||
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
|
||||
};
|
||||
// Messerrunde auf dem Match-Server: Spieler bekommen die Verbindungsdaten schon in der Lobby
|
||||
const knife = match.status === 'lobby' && row.knife_at && row.status === 'ready';
|
||||
if (match.status === 'lobby' && match.veto_start === 'knife') view.knife = { ready: !!knife, phase: knife ? row.knife_phase : null };
|
||||
if (viewer.isOrganizer) {
|
||||
// Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen")
|
||||
view.requestedAt = row.started_at ?? null;
|
||||
if (row.host) view.address = { host: row.host, port: row.port, tvPort: row.tv_port };
|
||||
}
|
||||
const configured = row.status === 'configured';
|
||||
if (knife && viewer.role !== 'public' && (viewer.isOrganizer || viewer.playerId)) view.connect = this.connectInfo(row);
|
||||
if (!configured || viewer.role === 'public') return view;
|
||||
|
||||
const isTeam = viewer.isOrganizer || !!viewer.playerId;
|
||||
const isSpectatorView = viewer.isOrganizer || viewer.isSpectator;
|
||||
if (isTeam) {
|
||||
view.connect = {
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
password: row.server_password,
|
||||
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
|
||||
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
|
||||
};
|
||||
}
|
||||
if (isTeam) view.connect = this.connectInfo(row);
|
||||
if (isSpectatorView && this.config.match.tvEnabled) {
|
||||
view.gotv = {
|
||||
host: row.host,
|
||||
@@ -1095,4 +1091,14 @@ export class MatchService extends EventEmitter {
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
connectInfo(row) {
|
||||
return {
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
password: row.server_password,
|
||||
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
|
||||
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,15 @@ const MAX_RESTARTS = 5;
|
||||
* Konsolenbefehle für eine Messerrunde: nur Messer, kein Geld, eine Runde, danach automatisch neues Spiel.
|
||||
* `pool` bestimmt den Spielmodus (Competitive 5v5 oder Wingman 2v2), `warmupSeconds` die Zeit zum Verbinden.
|
||||
*/
|
||||
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90 }) {
|
||||
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90, password = '', endlessWarmup = false }) {
|
||||
return {
|
||||
map,
|
||||
beforeMapChange: [
|
||||
`game_type ${Number(pool.gameType)}`,
|
||||
`game_mode ${Number(pool.gameMode)}`,
|
||||
'sv_password ""',
|
||||
'tv_enable 0',
|
||||
`sv_password "${password}"`,
|
||||
// Auf dem Match-Server (mit Passwort) bleibt CSTV, wie es ist: das Match braucht es danach
|
||||
...(password ? [] : ['tv_enable 0']),
|
||||
`changelevel ${map}`,
|
||||
],
|
||||
afterMapChange: [
|
||||
@@ -56,15 +57,38 @@ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, w
|
||||
'mp_autoteambalance 0',
|
||||
'mp_limitteams 0',
|
||||
'mp_autokick 0',
|
||||
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde
|
||||
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde. Endlos: bis der Organisator sie startet.
|
||||
'mp_do_warmup_period 1',
|
||||
`mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`,
|
||||
'mp_warmup_pausetimer 0',
|
||||
`mp_warmup_pausetimer ${endlessWarmup ? 1 : 0}`,
|
||||
'mp_warmup_start',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Messer-Einstellungen auf CS2-Standard zurück, bevor auf demselben Server das Match eingerichtet wird.
|
||||
* Was die Match-Konfiguration selbst setzt (Startgeld, Kaufzeit, Rundenzeit …), steht dort.
|
||||
*/
|
||||
export const KNIFE_UNDO_COMMANDS = [
|
||||
'mp_ct_default_secondary weapon_hkp2000',
|
||||
'mp_t_default_secondary weapon_glock',
|
||||
'mp_ct_default_primary ""',
|
||||
'mp_t_default_primary ""',
|
||||
'mp_ct_default_grenades ""',
|
||||
'mp_t_default_grenades ""',
|
||||
'mp_give_player_c4 1',
|
||||
'mp_maxmoney 16000',
|
||||
'mp_afterroundmoney 0',
|
||||
'mp_free_armor 0',
|
||||
'mp_weapons_allow_map_placed 1',
|
||||
'mp_death_drop_gun 1',
|
||||
'mp_death_drop_grenade 2',
|
||||
'mp_match_end_restart 0',
|
||||
'mp_roundtime_defuse 1.92',
|
||||
'mp_roundtime_hostage 1.92',
|
||||
];
|
||||
|
||||
/** Befehle, mit denen ein laufender Messer-Server für die nächsten Teams zurückgesetzt wird. */
|
||||
export const KNIFE_RESET_COMMANDS = ['mp_warmup_start'];
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
||||
import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js';
|
||||
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
||||
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
||||
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||||
@@ -305,7 +306,7 @@ export class ServerManager {
|
||||
VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL,
|
||||
@rcon, @server, @tv, @key, @attempts, @now, @now)
|
||||
ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated,
|
||||
status = 'starting', message = excluded.message, handle = NULL, host = NULL, port = NULL, tv_port = NULL,
|
||||
status = 'starting', message = excluded.message, handle = NULL, knife_at = NULL, knife_phase = NULL, host = NULL, port = NULL, tv_port = NULL,
|
||||
rcon_password = excluded.rcon_password, server_password = excluded.server_password,
|
||||
tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts,
|
||||
started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL,
|
||||
@@ -406,12 +407,66 @@ export class ServerManager {
|
||||
maybeConfigure(matchId) {
|
||||
const match = this.service.getMatch(matchId);
|
||||
const row = this.row(matchId);
|
||||
// Seitenwahl per Messerrunde: derselbe Server dient vor dem Veto als Messer-Server
|
||||
if (this.wantsKnife(match, row) && !row.knife_at) {
|
||||
this.configureKnife(matchId).catch((err) => this.logger.error(err));
|
||||
return;
|
||||
}
|
||||
// Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden
|
||||
if (['server_setup', 'live'].includes(match.status) && row?.status === 'ready') {
|
||||
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** Match in der Lobby mit Messerrunde und hochgefahrenem Server. */
|
||||
wantsKnife(match, row) {
|
||||
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
|
||||
}
|
||||
|
||||
/** Messerrunde auf dem Match-Server einrichten: Aufwärmphase ohne Ende, Passwort des Matches. */
|
||||
async configureKnife(matchId) {
|
||||
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||||
this.busy.add(matchId);
|
||||
try {
|
||||
const row = this.row(matchId);
|
||||
const match = this.service.getMatch(matchId);
|
||||
const pool = this.service.poolFor(match);
|
||||
const map = pool.maps.some((m) => m.id === this.config.knife?.map) ? this.config.knife.map : pool.maps[0].id;
|
||||
this.update(matchId, { message: 'Messerrunde wird eingerichtet' });
|
||||
try {
|
||||
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map, pool, password: row.server_password, endlessWarmup: true }));
|
||||
} catch (err) {
|
||||
this.update(matchId, { knife_at: null, knife_phase: null, message: `Messerrunde konnte nicht eingerichtet werden: ${err.message}` });
|
||||
this.service.log(matchId, 'server', `Messerrunde nicht eingerichtet: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
this.update(matchId, { knife_at: Date.now(), knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
|
||||
this.service.log(matchId, 'server', 'Server für die Messerrunde bereit (Aufwärmphase)');
|
||||
} finally {
|
||||
this.busy.delete(matchId);
|
||||
}
|
||||
// Ist das Veto inzwischen durch, gleich das Match einrichten (der Aufruf dafür kam, während wir beschäftigt waren)
|
||||
if (this.service.getMatch(matchId).status !== 'lobby') this.maybeConfigure(matchId);
|
||||
}
|
||||
|
||||
/** Organisator: Messerrunde starten (Aufwärmphase beenden) oder wiederholen (zurück in die Aufwärmphase). */
|
||||
async knifeAction(viewer, what) {
|
||||
this.service.require(viewer, 'organizer');
|
||||
const match = this.service.getMatch(viewer.matchId);
|
||||
const row = this.row(viewer.matchId);
|
||||
if (match.status !== 'lobby' || !row?.knife_at) throw new HttpError(409, 'Der Server ist noch nicht für die Messerrunde bereit', 'wrong_state');
|
||||
const start = what === 'start';
|
||||
try {
|
||||
await this.adapter.sendCommands(JSON.parse(row.handle), start ? ['mp_warmup_pausetimer 0', 'mp_warmup_end'] : ['mp_warmup_pausetimer 1', 'mp_warmup_start']);
|
||||
} catch (err) {
|
||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||
}
|
||||
this.update(viewer.matchId, start
|
||||
? { knife_phase: 'live', message: 'Messerrunde läuft' }
|
||||
: { knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
|
||||
this.service.log(viewer.matchId, 'server', start ? 'Messerrunde gestartet' : 'Messerrunde zurück in die Aufwärmphase');
|
||||
}
|
||||
|
||||
async configure(matchId) {
|
||||
const row = this.row(matchId);
|
||||
if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server');
|
||||
@@ -444,6 +499,8 @@ export class ServerManager {
|
||||
});
|
||||
this.simulator.stop(matchId);
|
||||
this.service.resetLiveScore(matchId, seriesMap.number);
|
||||
// Lief vorher die Messerrunde auf diesem Server, deren Einstellungen zurücknehmen
|
||||
if (row.knife_at) plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange];
|
||||
try {
|
||||
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
||||
} catch (err) {
|
||||
@@ -454,6 +511,8 @@ export class ServerManager {
|
||||
this.update(matchId, {
|
||||
status: 'configured',
|
||||
configured_at: Date.now(),
|
||||
knife_at: null,
|
||||
knife_phase: null,
|
||||
message: this.adapter.simulated
|
||||
? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server`
|
||||
: `${mapName} geladen – Aufwärmphase läuft`,
|
||||
@@ -482,6 +541,11 @@ export class ServerManager {
|
||||
await this.provision(matchId);
|
||||
return;
|
||||
}
|
||||
if (this.wantsKnife(match, row)) {
|
||||
this.service.log(matchId, 'server', 'Organisator richtet die Messerrunde neu ein');
|
||||
this.configureKnife(matchId).catch((err) => this.logger.error(err));
|
||||
return;
|
||||
}
|
||||
if (row.status === 'config_failed' || (row.status === 'ready' && ['server_setup', 'live'].includes(match.status))) {
|
||||
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
|
||||
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||
@@ -609,6 +673,8 @@ export class ServerManager {
|
||||
const webActive = this.service.presence(row.match_id).size > 0;
|
||||
// Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder
|
||||
let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0);
|
||||
// Spieler auf dem Server während der Messerrunde: in Benutzung
|
||||
if (row.knife_at && row.player_count > 0) continue;
|
||||
if (row.status === 'configured' && row.player_count !== null) {
|
||||
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
|
||||
if (row.player_count > 0) continue;
|
||||
|
||||
@@ -713,3 +713,53 @@ test('Konsole: nur für Admins, Ausgabe kommt live, unbekannte Server 404', asyn
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user