Veto: Seitenwahl per Messerrunde, freie Messer-Server in der Lobby, Organisator legt fest, wer beginnt

This commit is contained in:
mregosar committed 2026-10-29 22:27:07 +01:00
1 parent 7b6ce01d05
commit cdf41df01c
9 files changed
+138 -8

No files matched your search

+1
View File
@@ -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. - 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“. - 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. - 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). - Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
### Server im Steam-Serverbrowser (LAN-Reiter) ### Server im Steam-Serverbrowser (LAN-Reiter)
+4 -1
View File
@@ -384,7 +384,10 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
<div className="admin-actions"> <div className="admin-actions">
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link> <Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
{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) => (
<span key={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')}</span>
))}
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })} {(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('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')} {configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
+1
View File
@@ -89,6 +89,7 @@ export default function CreateMatch({ config }) {
<option value="coin">Münzwurf beim Veto-Start</option> <option value="coin">Münzwurf beim Veto-Start</option>
<option value="A">{form.teamA || 'Team A'}</option> <option value="A">{form.teamA || 'Team A'}</option>
<option value="B">{form.teamB || 'Team B'}</option> <option value="B">{form.teamB || 'Team B'}</option>
<option value="knife">Messerrunde: Organisator legt danach fest, wer beginnt</option>
</select> </select>
</label> </label>
+67 -1
View File
@@ -170,7 +170,21 @@ function Stage(ctx) {
{missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)} {missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)}
</ul> </ul>
)} )}
{isOrg ? ( {state.vetoStart === 'knife' && <KnifeRound state={state} />}
{isOrg && state.vetoStart === 'knife' ? (
<div className="knife-decide">
<p>Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.</p>
<div className="row">
{['A', 'B'].map((t) => (
<button key={t} className={`btn btn-lg knife-team-${t.toLowerCase()}`} disabled={!state.canStartVeto || !!busy}
onClick={() => act('veto/start', { firstTeam: t }, `Veto gestartet – ${state.teams[t].name} beginnt`)}>
{state.teams[t].name} beginnt
</button>
))}
</div>
{!state.canStartVeto && <p className="small muted">Die Knöpfe werden aktiv, sobald beide Teams bereit sind.</p>}
</div>
) : isOrg ? (
<> <>
<button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy} <button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy}
onClick={() => act('veto/start', null, 'Veto gestartet')}> onClick={() => act('veto/start', null, 'Veto gestartet')}>
@@ -178,6 +192,8 @@ function Stage(ctx) {
</button> </button>
{!state.canStartVeto && <p className="small muted">Der Knopf wird aktiv, sobald beide Teams bereit sind.</p>} {!state.canStartVeto && <p className="small muted">Der Knopf wird aktiv, sobald beide Teams bereit sind.</p>}
</> </>
) : state.vetoStart === 'knife' ? (
<p className="small muted">Nach der Messerrunde legt der Organisator fest, welches Team das Veto beginnt.</p>
) : ( ) : (
<p className="small muted"> <p className="small muted">
{missing.length === 0 {missing.length === 0
@@ -200,6 +216,56 @@ function Stage(ctx) {
} }
/** Sagt konkret, was einem Team noch fehlt, damit es als bereit gilt. */ /** 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 (
<div className="knife-round">
<h3>Seitenwahl per Messerrunde</h3>
<p className="small">
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.
</p>
{servers === null ? (
<p className="small muted">Suche freie Messer-Server …</p>
) : shown.length === 0 ? (
<p className="small tone-pending">Gerade ist kein Messer-Server frei. Fragt die Turnierleitung, oder wartet, bis einer frei wird.</p>
) : (
<>
{!matching.length && <p className="small muted">Kein freier Server mit eurer Teamgröße, hier die anderen freien:</p>}
<ul className="knife-list">
{shown.map((k) => (
<li key={k.id}>
<strong>{k.name}</strong>
{k.modeShort && <span className="knife-mode">{k.modeShort}</span>}
<span className="muted small">{k.mapName}</span>
<code>{k.connect}</code>
<a className="btn btn-sm btn-primary" href={k.steamUrl}>Verbinden</a>
</li>
))}
</ul>
</>
)}
</div>
);
}
function readinessGap(team, requireFull) { function readinessGap(team, requireFull) {
const r = team.readiness; const r = team.readiness;
const gaps = []; const gaps = [];
+8
View File
@@ -645,3 +645,11 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.settings-nav-item small { display: none; } .settings-nav-item small { display: none; }
.settings-nav-item.is-active { border-color: var(--primary); } .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); }
+1 -1
View File
@@ -195,7 +195,7 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
action('server/pause', (v) => manager.setPaused(v, true)); action('server/pause', (v) => manager.setPaused(v, true));
action('server/resume', (v) => manager.setPaused(v, false)); action('server/resume', (v) => manager.setPaused(v, false));
action('series/next', (v) => manager.loadNextMap(v)); 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( action('veto/action', (v, b) => service.vetoAction(
adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }), adminViewer(v.matchId, { team: b.team, isCaptain: true, role: 'captain' }),
{ stepIndex: b.stepIndex, map: b.map, side: b.side }, { stepIndex: b.stepIndex, map: b.map, side: b.side },
+1 -1
View File
@@ -117,7 +117,7 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
action('kick', (v, b) => service.kick(v, b.playerId)); action('kick', (v, b) => service.kick(v, b.playerId));
action('captain', (v, b) => service.setCaptain(v, b.playerId)); action('captain', (v, b) => service.setCaptain(v, b.playerId));
action('codes/regenerate', (v, b) => service.regenerateCode(v, b.target)); 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('veto/action', (v, b) => service.vetoAction(v, b));
action('server/retry', (v) => manager.retry(v)); action('server/retry', (v) => manager.retry(v));
action('server/stop', (v) => manager.stopByOrganizer(v)); action('server/stop', (v) => manager.stopByOrganizer(v));
+14 -4
View File
@@ -150,7 +150,8 @@ export class MatchService extends EventEmitter {
if (!formatInfo?.supported) { if (!formatInfo?.supported) {
throw new HttpError(400, `${String(format).toUpperCase()} wird für ${pool.label} nicht unterstützt`, 'unsupported_format'); 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 nameA = cleanName(teamA, 'Name von Team A');
const nameB = cleanName(teamB, 'Name von Team B'); const nameB = cleanName(teamB, 'Name von Team B');
if (nameA.toLowerCase() === nameB.toLowerCase()) { if (nameA.toLowerCase() === nameB.toLowerCase()) {
@@ -326,7 +327,11 @@ export class MatchService extends EventEmitter {
// ---------- Veto ---------- // ---------- 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.require(viewer, 'organizer');
this.db.transaction(() => { this.db.transaction(() => {
const match = this.getMatch(viewer.matchId); const match = this.getMatch(viewer.matchId);
@@ -335,11 +340,16 @@ export class MatchService extends EventEmitter {
if (!readiness.A.ready || !readiness.B.ready) { if (!readiness.A.ready || !readiness.B.ready) {
throw new HttpError(409, 'Beide Teams müssen vollständig und bereit sein', 'teams_not_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 this.db
.prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?") .prepare("UPDATE matches SET status = 'veto', first_team = ?, veto_started_at = ?, updated_at = ? WHERE id = ?")
.run(firstTeam, this.now(), this.now(), match.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.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`);
this.notify('veto_start', match.id, { this.notify('veto_start', match.id, {
title: '🎮 Veto gestartet', title: '🎮 Veto gestartet',
+41
View File
@@ -607,3 +607,44 @@ test('Map-Pools im Admin: Auswahl gilt für neue Matches, Veto passt sich an, la
await close(); 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();
}
});