From cdf41df01cb6afa0d6ba6a7b6d42ec5b2bb2d982 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Thu, 29 Oct 2026 22:27:07 +0100 Subject: [PATCH] Veto: Seitenwahl per Messerrunde, freie Messer-Server in der Lobby, Organisator legt fest, wer beginnt --- README.md | 1 + client/src/pages/AdminPage.jsx | 5 ++- client/src/pages/CreateMatch.jsx | 1 + client/src/pages/MatchPage.jsx | 68 +++++++++++++++++++++++++++++++- client/src/styles.css | 8 ++++ server/src/adminRoutes.js | 2 +- server/src/http.js | 2 +- server/src/matchService.js | 18 +++++++-- server/test/api.test.js | 41 +++++++++++++++++++ 9 files changed, 138 insertions(+), 8 deletions(-) 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 }) { + 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) => ( + + ))} +
    + {!state.canStartVeto &&

    Die Knöpfe werden aktiv, sobald beide Teams bereit sind.

    } +
    + ) : isOrg ? ( <> {!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(); + } +});