diff --git a/README.md b/README.md
index 10ba4bf..368b544 100644
--- a/README.md
+++ b/README.md
@@ -279,6 +279,7 @@ Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Mes
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
- Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches.
+- **Im Match:** Wählt der Organisator beim Erstellen „Wer beginnt das Veto?“ → **Messerrunde**, zeigt die Lobby beiden Teams die freien Messer-Server (zuerst die mit passender Teamgröße) mit „Verbinden“. Nach der Runde klickt der Organisator statt „Veto starten“ auf „Team A beginnt“ bzw. „Team B beginnt“. Das gewählte Team beginnt das Veto. Im Admin-Bereich gibt es dieselben zwei Knöpfe in der Matchkarte.
- Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
### Server im Steam-Serverbrowser (LAN-Reiter)
diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx
index 748f81a..544d6be 100644
--- a/client/src/pages/AdminPage.jsx
+++ b/client/src/pages/AdminPage.jsx
@@ -384,7 +384,10 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
Ansehen
- {m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
+ {m.status === 'lobby' && m.vetoStart !== 'knife' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
+ {m.status === 'lobby' && m.vetoStart === 'knife' && ['A', 'B'].map((t) => (
+
{btn(`${m.teams[t].name} beginnt das Veto`, { path: '/veto/start', body: { firstTeam: t }, confirm: `Messerrunde vorbei: ${m.teams[t].name} beginnt das Veto?`, success: 'Veto gestartet' }, 'btn-primary')}
+ ))}
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
diff --git a/client/src/pages/CreateMatch.jsx b/client/src/pages/CreateMatch.jsx
index bb10937..2532d00 100644
--- a/client/src/pages/CreateMatch.jsx
+++ b/client/src/pages/CreateMatch.jsx
@@ -89,6 +89,7 @@ export default function CreateMatch({ config }) {
Münzwurf beim Veto-Start
{form.teamA || 'Team A'}
{form.teamB || 'Team B'}
+
Messerrunde: Organisator legt danach fest, wer beginnt
diff --git a/client/src/pages/MatchPage.jsx b/client/src/pages/MatchPage.jsx
index e950869..01c9702 100644
--- a/client/src/pages/MatchPage.jsx
+++ b/client/src/pages/MatchPage.jsx
@@ -170,7 +170,21 @@ function Stage(ctx) {
{missing.map((t) =>
{t.name}: {readinessGap(t, state.requireFullTeams)})}
)}
- {isOrg ? (
+ {state.vetoStart === 'knife' &&
}
+ {isOrg && state.vetoStart === 'knife' ? (
+
+
Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.
+
+ {['A', 'B'].map((t) => (
+ act('veto/start', { firstTeam: t }, `Veto gestartet – ${state.teams[t].name} beginnt`)}>
+ {state.teams[t].name} beginnt
+
+ ))}
+
+ {!state.canStartVeto &&
Die Knöpfe werden aktiv, sobald beide Teams bereit sind.
}
+
+ ) : isOrg ? (
<>
act('veto/start', null, 'Veto gestartet')}>
@@ -178,6 +192,8 @@ function Stage(ctx) {
{!state.canStartVeto &&
Der Knopf wird aktiv, sobald beide Teams bereit sind.
}
>
+ ) : state.vetoStart === 'knife' ? (
+
Nach der Messerrunde legt der Organisator fest, welches Team das Veto beginnt.
) : (
{missing.length === 0
@@ -200,6 +216,56 @@ function Stage(ctx) {
}
/** Sagt konkret, was einem Team noch fehlt, damit es als bereit gilt. */
+/**
+ * Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst.
+ * Frei = niemand drauf (oder Spielerzahl unbekannt).
+ */
+function KnifeRound({ state }) {
+ const [servers, setServers] = useState(null);
+ useEffect(() => {
+ let alive = true;
+ const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => alive && setServers([]));
+ load();
+ const t = setInterval(load, 10_000);
+ return () => {
+ alive = false;
+ clearInterval(t);
+ };
+ }, []);
+ const free = (servers ?? []).filter((k) => !k.playerCount);
+ const matching = free.filter((k) => k.mode === state.mode);
+ const shown = matching.length ? matching : free;
+ return (
+
+
Seitenwahl per Messerrunde
+
+ Beide Teams gehen zusammen auf einen freien Messer-Server und spielen eine Runde nur mit Messer. Danach legt der Organisator fest,
+ welches Team das Veto beginnt.
+
+ {servers === null ? (
+
Suche freie Messer-Server …
+ ) : shown.length === 0 ? (
+
Gerade ist kein Messer-Server frei. Fragt die Turnierleitung, oder wartet, bis einer frei wird.
+ ) : (
+ <>
+ {!matching.length &&
Kein freier Server mit eurer Teamgröße, hier die anderen freien:
}
+
+ {shown.map((k) => (
+
+ {k.name}
+ {k.modeShort && {k.modeShort} }
+ {k.mapName}
+ {k.connect}
+ Verbinden
+
+ ))}
+
+ >
+ )}
+
+ );
+}
+
function readinessGap(team, requireFull) {
const r = team.readiness;
const gaps = [];
diff --git a/client/src/styles.css b/client/src/styles.css
index c20b91d..1143bc9 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -645,3 +645,11 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.settings-nav-item small { display: none; }
.settings-nav-item.is-active { border-color: var(--primary); }
}
+/* Seitenwahl per Messerrunde in der Lobby */
+.knife-round { margin: 14px 0; padding: 14px 16px; border: 1px solid var(--line); border-left: 3px solid var(--brand-red); border-radius: var(--radius); background: var(--surface-2); text-align: left; }
+.knife-round h3 { margin: 0 0 6px; font-size: 1.05rem; }
+.knife-round .knife-list { margin-top: 10px; }
+.knife-decide { margin: 6px 0 4px; }
+.knife-decide .row { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
+.knife-team-a { border-color: var(--team-a); color: var(--team-a); }
+.knife-team-b { border-color: var(--team-b); color: var(--team-b); }
diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js
index 3331474..2742660 100644
--- a/server/src/adminRoutes.js
+++ b/server/src/adminRoutes.js
@@ -195,7 +195,7 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
action('server/pause', (v) => manager.setPaused(v, true));
action('server/resume', (v) => manager.setPaused(v, false));
action('series/next', (v) => manager.loadNextMap(v));
- action('veto/start', (v) => service.startVeto(v));
+ action('veto/start', (v, b) => service.startVeto(v, { firstTeam: b.firstTeam }));
action('veto/action', (v, b) => service.vetoAction(
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
{ stepIndex: b.stepIndex, map: b.map, side: b.side },
diff --git a/server/src/http.js b/server/src/http.js
index 74d61cc..a06dc0d 100644
--- a/server/src/http.js
+++ b/server/src/http.js
@@ -117,7 +117,7 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
action('kick', (v, b) => service.kick(v, b.playerId));
action('captain', (v, b) => service.setCaptain(v, b.playerId));
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target));
- action('veto/start', (v) => service.startVeto(v));
+ action('veto/start', (v, b) => service.startVeto(v, { firstTeam: b.firstTeam }));
action('veto/action', (v, b) => service.vetoAction(v, b));
action('server/retry', (v) => manager.retry(v));
action('server/stop', (v) => manager.stopByOrganizer(v));
diff --git a/server/src/matchService.js b/server/src/matchService.js
index 03f52ec..c379768 100644
--- a/server/src/matchService.js
+++ b/server/src/matchService.js
@@ -150,7 +150,8 @@ export class MatchService extends EventEmitter {
if (!formatInfo?.supported) {
throw new HttpError(400, `${String(format).toUpperCase()} wird für ${pool.label} nicht unterstützt`, 'unsupported_format');
}
- if (!['coin', 'A', 'B'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input');
+ // knife = Seitenwahl per Messerrunde: der Organisator legt nach der Runde fest, wer das Veto beginnt
+ if (!['coin', 'A', 'B', 'knife'].includes(vetoStart)) throw new HttpError(400, 'Ungültige Veto-Startregel', 'invalid_input');
const nameA = cleanName(teamA, 'Name von Team A');
const nameB = cleanName(teamB, 'Name von Team B');
if (nameA.toLowerCase() === nameB.toLowerCase()) {
@@ -326,7 +327,11 @@ export class MatchService extends EventEmitter {
// ---------- Veto ----------
- startVeto(viewer) {
+ /**
+ * Veto starten. Bei Seitenwahl per Messerrunde (veto_start = knife) gibt der Organisator mit
+ * `firstTeam` an, welches Team das Veto beginnt.
+ */
+ startVeto(viewer, { firstTeam: chosen } = {}) {
this.require(viewer, 'organizer');
this.db.transaction(() => {
const match = this.getMatch(viewer.matchId);
@@ -335,11 +340,16 @@ export class MatchService extends EventEmitter {
if (!readiness.A.ready || !readiness.B.ready) {
throw new HttpError(409, 'Beide Teams müssen vollständig und bereit sein', 'teams_not_ready');
}
- const firstTeam = match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B');
+ const knife = match.veto_start === 'knife';
+ if (knife && !['A', 'B'].includes(chosen)) {
+ throw new HttpError(400, 'Nach der Messerrunde festlegen, welches Team das Veto beginnt', 'first_team_required');
+ }
+ const firstTeam = knife ? chosen : match.first_team ?? (Math.random() < 0.5 ? 'A' : 'B');
this.db
.prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?")
.run(firstTeam, this.now(), this.now(), match.id);
- const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
+ const how = knife ? 'nach der Messerrunde vom Organisator festgelegt'
+ : match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt';
this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
this.notify('veto_start', match.id, {
title: '🎮 Veto gestartet',
diff --git a/server/test/api.test.js b/server/test/api.test.js
index 0c3cf9e..b677871 100644
--- a/server/test/api.test.js
+++ b/server/test/api.test.js
@@ -607,3 +607,44 @@ test('Map-Pools im Admin: Auswahl gilt für neue Matches, Veto passt sich an, la
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();
+ }
+});