Compare commits

...
4 Commits
Author SHA1 Message Date
mregosar 5503995791 Merge fix/keine-map-abstimmung in main 2026-11-01 19:44:25 +01:00
mregosar be42beb260 Keine Map-Abstimmung nach Spielende (mp_endmatch_votenextmap 0)
Gilt für Match- und Messer-Server. Die nächste Map legt das Veto fest, die App lädt sie selbst.
2026-11-01 19:44:25 +01:00
mregosar cc6e7f65be Merge fix/messerrunde-wiederholen in main 2026-11-01 19:28:41 +01:00
mregosar 1492c280d5 Messerrunde: Einrichten bei Fehlern automatisch wiederholen
Nach einer frischen Installation dauert das erste Laden einer Map oft länger als die Wartezeit. Die App versucht das Einrichten jetzt bis zu dreimal (alle 10 s). Scheitern alle Versuche, zeigt die Lobby den Fehler und dem Organisator den Knopf "Messerrunde neu einrichten".
2026-11-01 19:23:52 +01:00
7 changed files with 71 additions and 4 deletions

No files matched your search

+10 -1
View File
@@ -235,7 +235,16 @@ function OwnKnifeServer({ state, isOrg, act, busy }) {
Für euer Match läuft ein eigener Server. Beide Teams verbinden sich, spielen dort eine Runde nur mit Messer, danach legt der 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. Organisator fest, welches Team das Veto beginnt. Nach dem Veto geht es auf demselben Server weiter.
</p> </p>
{!knife.ready ? ( {knife.failed ? (
<>
<p className="small tone-error">{server.message}</p>
{isOrg ? (
<button className="btn btn-sm btn-primary" disabled={!!busy} onClick={() => act('server/retry', null, 'Messerrunde wird neu eingerichtet')}>
Messerrunde neu einrichten
</button>
) : <p className="small muted">Der Organisator kann die Messerrunde neu einrichten.</p>}
</>
) : !knife.ready ? (
<p className="small tone-pending"> <p className="small tone-pending">
<span className="status-dot tone-pending pulse" /> Der Server fährt hoch, die Verbindungsdaten erscheinen gleich hier. <span className="status-dot tone-pending pulse" /> Der Server fährt hoch, die Verbindungsdaten erscheinen gleich hier.
{server.message && <span className="muted"> ({server.message})</span>} {server.message && <span className="muted"> ({server.message})</span>}
+5 -1
View File
@@ -1070,7 +1070,11 @@ export class MatchService extends EventEmitter {
}; };
// Messerrunde auf dem Match-Server: Spieler bekommen die Verbindungsdaten schon in der Lobby // Messerrunde auf dem Match-Server: Spieler bekommen die Verbindungsdaten schon in der Lobby
const knife = match.status === 'lobby' && row.knife_at && row.status === 'ready'; 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 (match.status === 'lobby' && match.veto_start === 'knife') {
// failed: alle automatischen Versuche fehlgeschlagen, der Organisator kann es von Hand wiederholen
const failed = !knife && row.status === 'ready' && String(row.message ?? '').startsWith('Messerrunde konnte nicht');
view.knife = { ready: !!knife, phase: knife ? row.knife_phase : null, failed };
}
if (viewer.isOrganizer) { if (viewer.isOrganizer) {
// Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen") // Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen")
view.requestedAt = row.started_at ?? null; view.requestedAt = row.started_at ?? null;
+2
View File
@@ -59,6 +59,8 @@ export function buildMapCommands({ match, pool, seriesMap, secrets, settings, lo
`mp_maxrounds ${isWingman ? settings.maxRoundsWingman : settings.maxRoundsCompetitive}`, `mp_maxrounds ${isWingman ? settings.maxRoundsWingman : settings.maxRoundsCompetitive}`,
`mp_overtime_enable ${settings.overtime ? 1 : 0}`, `mp_overtime_enable ${settings.overtime ? 1 : 0}`,
'mp_match_can_clinch 1', 'mp_match_can_clinch 1',
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
'mp_endmatch_votenextmap 0',
'mp_autoteambalance 0', 'mp_autoteambalance 0',
'mp_limitteams 0', 'mp_limitteams 0',
// Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end) // Aufwärmphase läuft, bis der Organisator das Match startet (mp_warmup_end)
+2
View File
@@ -30,6 +30,8 @@ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, w
// Eine Runde entscheidet, danach startet das Spiel von selbst neu (für die nächsten Teams) // Eine Runde entscheidet, danach startet das Spiel von selbst neu (für die nächsten Teams)
'mp_maxrounds 1', 'mp_maxrounds 1',
'mp_match_can_clinch 1', 'mp_match_can_clinch 1',
// Keine Map-Abstimmung nach Spielende: die nächste Map legt das Veto fest
'mp_endmatch_votenextmap 0',
'mp_match_end_restart 1', 'mp_match_end_restart 1',
'mp_match_restart_delay 10', 'mp_match_restart_delay 10',
'mp_halftime 0', 'mp_halftime 0',
+20 -1
View File
@@ -18,6 +18,9 @@ export const SERVER_STATUS = {
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed']; const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
const SLOW_POLL_MS = 30_000; const SLOW_POLL_MS = 30_000;
// Messerrunde: so oft wird das Einrichten versucht, bevor der Organisator selbst neu starten muss
const KNIFE_RETRIES = 3;
const KNIFE_RETRY_DELAY_MS = 10_000;
/** /**
* Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle * Steuert den Lebenszyklus der Match-Server. Kennt nur die Adapter-Schnittstelle
@@ -29,6 +32,9 @@ export class ServerManager {
this.busy = new Set(); this.busy = new Set();
this.simulator = new DemoMatchSimulator(); this.simulator = new DemoMatchSimulator();
this.autoAdvance = new Map(); this.autoAdvance = new Map();
// Fehlversuche beim Einrichten der Messerrunde je Match (für die automatische Wiederholung)
this.knifeAttempts = new Map();
this.knifeRetryDelayMs = KNIFE_RETRY_DELAY_MS;
this.lastPoll = new Map(); this.lastPoll = new Map();
this.timers = []; this.timers = [];
} }
@@ -444,10 +450,22 @@ export class ServerManager {
try { try {
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map, pool, password: row.server_password, endlessWarmup: true })); await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map, pool, password: row.server_password, endlessWarmup: true }));
} catch (err) { } catch (err) {
this.update(matchId, { knife_at: null, knife_phase: null, message: `Messerrunde konnte nicht eingerichtet werden: ${err.message}` }); // Das erste Laden einer Map dauert nach einer frischen Installation oft länger: von selbst noch einmal versuchen
const attempt = (this.knifeAttempts.get(matchId) ?? 0) + 1;
this.knifeAttempts.set(matchId, attempt);
const again = attempt < KNIFE_RETRIES;
this.update(matchId, {
knife_at: null,
knife_phase: null,
message: again
? `Messerrunde wird erneut eingerichtet (Versuch ${attempt + 1} von ${KNIFE_RETRIES}): ${err.message}`
: `Messerrunde konnte nicht eingerichtet werden: ${err.message}`,
});
this.service.log(matchId, 'server', `Messerrunde nicht eingerichtet: ${err.message}`); this.service.log(matchId, 'server', `Messerrunde nicht eingerichtet: ${err.message}`);
if (again) this.timers.push(setTimeout(() => this.maybeConfigure(matchId), this.knifeRetryDelayMs));
return; return;
} }
this.knifeAttempts.delete(matchId);
this.update(matchId, { knife_at: Date.now(), knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' }); 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)'); this.service.log(matchId, 'server', 'Server für die Messerrunde bereit (Aufwärmphase)');
} finally { } finally {
@@ -555,6 +573,7 @@ export class ServerManager {
return; return;
} }
if (this.wantsKnife(match, row)) { if (this.wantsKnife(match, row)) {
this.knifeAttempts.delete(matchId);
this.service.log(matchId, 'server', 'Organisator richtet die Messerrunde neu ein'); this.service.log(matchId, 'server', 'Organisator richtet die Messerrunde neu ein');
this.configureKnife(matchId).catch((err) => this.logger.error(err)); this.configureKnife(matchId).catch((err) => this.logger.error(err));
return; return;
+31 -1
View File
@@ -730,7 +730,7 @@ test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, d
await app.manager.poll(); await app.manager.poll();
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde eingerichtet' }); await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde eingerichtet' });
const player = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body; const player = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body;
assert.deepEqual(player.server.knife, { ready: true, phase: 'warmup' }); assert.deepEqual(player.server.knife, { ready: true, phase: 'warmup', failed: false });
assert.equal(player.server.connect.password, app.manager.row(id).server_password, 'Spieler bekommen das Passwort des Matches'); 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; const pub = (await call('GET', `/matches/${id}`)).body;
assert.equal(pub.server?.connect, undefined, 'öffentlich keine Verbindungsdaten'); assert.equal(pub.server?.connect, undefined, 'öffentlich keine Verbindungsdaten');
@@ -775,3 +775,33 @@ test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, d
await close(); await close();
} }
}); });
test('Messerrunde: Einrichten wird bei Fehlern von selbst wiederholt, danach kann der Organisator neu starten', async () => {
const { app, call, close } = await setup();
try {
app.manager.knifeRetryDelayMs = 0;
const configure = app.adapter.configure.bind(app.adapter);
let failures = 2;
app.adapter.configure = async (h, plan) => {
if (failures > 0) { failures -= 1; throw new Error('Map de_mirage wurde nicht rechtzeitig geladen'); }
return configure(h, plan);
};
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'knife' } });
const { id, organizerToken: org } = created.body;
await app.manager.poll();
// Zwei Fehlschläge, der dritte Versuch klappt ohne Zutun
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde nach Wiederholung eingerichtet' });
// Scheitern alle Versuche, bekommt der Organisator den Knopf
failures = 99;
app.manager.update(id, { knife_at: null, knife_phase: null });
app.manager.knifeAttempts.delete(id);
app.manager.maybeConfigure(id);
await waitFor(async () => (await call('GET', `/matches/${id}`, { token: org })).body.server.knife.failed, { label: 'als fehlgeschlagen gemeldet' });
failures = 0;
assert.equal((await call('POST', `/matches/${id}/server/retry`, { token: org })).status, 200);
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'von Hand neu eingerichtet' });
} finally {
await close();
}
});
+1
View File
@@ -78,6 +78,7 @@ test('Log-Weiterleitung wird vor dem Mapwechsel gesetzt, unsichere URLs nicht',
}; };
const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange; const ok = buildMapCommands({ ...base, logUrl: 'http://192.168.178.31:3000/api/logs/abc_D-1/KEY123' }).beforeMapChange;
assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"')); assert.ok(ok.includes('logaddress_add_http "http://192.168.178.31:3000/api/logs/abc_D-1/KEY123"'));
assert.ok(buildMapCommands({ ...base, logUrl: 'http://x/api/logs/a/b' }).afterMapChange.includes('mp_endmatch_votenextmap 0'), 'keine Map-Abstimmung nach dem Match');
assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke')); assert.ok(ok.indexOf('log on') < ok.indexOf('changelevel de_nuke'));
const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange; const bad = buildMapCommands({ ...base, logUrl: 'http://x"; quit' }).beforeMapChange;
assert.ok(!bad.some((c) => c.startsWith('logaddress'))); assert.ok(!bad.some((c) => c.startsWith('logaddress')));