diff --git a/client/src/pages/MatchPage.jsx b/client/src/pages/MatchPage.jsx
index 7abde21..494bb51 100644
--- a/client/src/pages/MatchPage.jsx
+++ b/client/src/pages/MatchPage.jsx
@@ -13,6 +13,7 @@ import EventLog from '../components/EventLog.jsx';
import MapImage from '../components/MapImage.jsx';
import SelfJoin from '../components/SelfJoin.jsx';
import ChatBox from '../components/ChatBox.jsx';
+import CopyField from '../components/CopyField.jsx';
export default function MatchPage() {
const { id } = useParams();
@@ -172,7 +173,7 @@ function Stage(ctx) {
{missing.map((t) =>
Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.
@@ -222,6 +223,50 @@ function Stage(ctx) {
* Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst.
* Frei = niemand drauf (oder Spielerzahl unbekannt).
*/
+/** Messerrunde auf dem eigenen Match-Server: derselbe Server wird nach dem Veto für das Match eingerichtet. */
+function OwnKnifeServer({ state, isOrg, act, busy }) {
+ const { server } = state;
+ const { knife, connect } = server;
+ const live = knife.phase === 'live';
+ return (
+
+
Seitenwahl per Messerrunde
+
+ Für euer Match läuft ein eigener Server. Beide Teams verbinden sich, spielen dort eine Runde nur mit Messer, danach legt der
+ Organisator fest, welches Team das Veto beginnt. Nach dem Veto geht es auf demselben Server weiter.
+
+ {!knife.ready ? (
+
+ Der Server fährt hoch, die Verbindungsdaten erscheinen gleich hier.
+ {server.message && ({server.message}) }
+
+ ) : (
+ <>
+
+
+ {live ? ' Messerrunde läuft' : ' Aufwärmphase: verbinden und warten, bis der Organisator die Runde startet'}
+ {server.playerCount != null && · {server.playerCount} auf dem Server }
+
+ {connect && (
+
+ )}
+ {isOrg && (
+
+ {!live && act('server/knife-start', null, 'Messerrunde gestartet')}>Messerrunde starten }
+ act('server/knife-reset', null, 'Zurück in der Aufwärmphase')}>
+ {live ? 'Runde wiederholen (Aufwärmphase)' : 'Aufwärmphase neu starten'}
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
function KnifeRound({ state }) {
const [servers, setServers] = useState(null);
useEffect(() => {
diff --git a/client/src/styles.css b/client/src/styles.css
index 3f14561..a541252 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -772,3 +772,6 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.pull-skipped span { color: var(--muted); }
.files-bar { flex: 1 1 160px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
.files-bar i { display: block; height: 100%; background: var(--primary); transition: width .4s ease; }
+.knife-connect { display: flex; flex-wrap: wrap; align-items: end; gap: 8px 12px; margin-top: 10px; }
+.knife-connect > :first-child { flex: 1 1 260px; }
+.knife-actions { margin-top: 10px; gap: 8px; flex-wrap: wrap; }
diff --git a/server/src/db.js b/server/src/db.js
index 7fdcf27..8e76740 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -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'");
diff --git a/server/src/http.js b/server/src/http.js
index 4c4de52..14ef1c0 100644
--- a/server/src/http.js
+++ b/server/src/http.js
@@ -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'));
diff --git a/server/src/matchService.js b/server/src/matchService.js
index 413ed75..72c3808 100644
--- a/server/src/matchService.js
+++ b/server/src/matchService.js
@@ -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}`,
+ };
+ }
}
diff --git a/server/src/servers/knifeServers.js b/server/src/servers/knifeServers.js
index 7e4b5f8..40be470 100644
--- a/server/src/servers/knifeServers.js
+++ b/server/src/servers/knifeServers.js
@@ -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'];
diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js
index 1e9ad6c..f385190 100644
--- a/server/src/servers/serverManager.js
+++ b/server/src/servers/serverManager.js
@@ -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;
diff --git a/server/test/api.test.js b/server/test/api.test.js
index 1ce8a38..78f5ffd 100644
--- a/server/test/api.test.js
+++ b/server/test/api.test.js
@@ -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();
+ }
+});