Veto: Seitenwahl per Messerrunde, freie Messer-Server in der Lobby, Organisator legt fest, wer beginnt
This commit is contained in:
1 parent
7b6ce01d05
commit
cdf41df01c
9 files changed
+138
-8
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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); }
|
||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user