Messerrunde auf dem eigenen Match-Server, danach derselbe Server fürs Match

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.
This commit is contained in:
mregosar committed 2026-11-01 18:25:07 +01:00
1 parent 2c57b8c170
commit 13320313ec
10 files changed
+220 -20

No files matched your search

+3
View File
@@ -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'");
+2
View File
@@ -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'));
+15 -9
View File
@@ -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}`,
};
}
}
+29 -5
View File
@@ -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'];
+67 -1
View File
@@ -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;
+50
View File
@@ -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();
}
});