From 0df86743530231361cb045c3ca075bfec71271b2 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sat, 28 Mar 2026 12:15:40 +0100 Subject: [PATCH] feat(TournamentService): implement seeded knockout match generation and enhance qualifier handling - Added a new function to build seeded legacy knockout matches, improving match pairing logic based on group affiliations. - Refactored qualifier selection process to utilize a ranked participants map, ensuring better handling of advancing participants and unused qualifiers. - Updated knockout match handling to incorporate seeded results, enhancing the overall tournament flow and participant management. --- backend/services/tournamentService.js | 98 ++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 11 deletions(-) diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 28ee7858..6f224335 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -302,6 +302,38 @@ function buildPreferredLegacyKnockoutMatches(qualifiersByGroup, advancingPerGrou return matches.length > 0 ? matches : null; } +function buildSeededLegacyKnockoutMatches(classQualifiers) { + const remaining = [...(classQualifiers || [])].sort(compareAdvancementCandidates); + const matches = []; + + while (remaining.length >= 2) { + const player1 = remaining.shift(); + if (!player1 || !player1.id) continue; + + let opponentIndex = remaining.length - 1; + while ( + opponentIndex >= 0 && + String(remaining[opponentIndex]?.groupId ?? '') === String(player1.groupId ?? '') + ) { + opponentIndex -= 1; + } + + if (opponentIndex < 0) { + opponentIndex = remaining.length - 1; + } + + const [player2] = remaining.splice(opponentIndex, 1); + if (!player2 || !player2.id || player1.id === player2.id) continue; + + matches.push({ player1, player2 }); + } + + return { + matches, + unusedQualifiers: remaining, + }; +} + const THIRD_PLACE_ROUND = 'Spiel um Platz 3'; class TournamentService { /** @@ -2832,7 +2864,7 @@ Ve // 2. Neues Turnier anlegen return map; }, {}); - const qualifiers = []; + const rankedParticipantsByClass = new Map(); for (const g of groups) { const classId = g.classId; const isDoubles = classId ? (classIsDoublesMap[classId] || false) : false; @@ -2941,23 +2973,58 @@ Ve // 2. Neues Turnier anlegen // Fallback: Nach ID return a.member.id - b.member.id; }); - // Füge classId und groupId zur Gruppe hinzu - // r.member ist entweder TournamentMember oder ExternalTournamentParticipant - qualifiers.push(...ranked.slice(0, tournament.advancingPerGroup).map((r, position) => { + const classKey = classId == null ? 'null' : String(classId); + const groupNumber = Number(g.number || g.groupNumber || g.sortOrder || g.id || 999); + const rankedParticipants = ranked.map((r, position) => { const member = r.member; - // Stelle sicher, dass id vorhanden ist if (!member || !member.id) { devLog(`[_determineQualifiers] Warning: Member without id found in group ${g.id}`); return null; } return { id: member.id, - classId: g.classId, + classId, groupId: g.id, isExternal: r.isExternal || false, - position: position + 1 // 1-basierte Position innerhalb der Gruppe (1., 2., 3., etc.) + position: position + 1, + points: Number(r.points || 0), + setDiff: Number((r.setsWon || 0) - (r.setsLost || 0)), + setsWon: Number(r.setsWon || 0), + pointsDiff: Number(r.pointsDiff || 0), + pointsWon: Number(r.pointsWon || 0), + groupNumber, }; - }).filter(q => q !== null)); + }).filter(q => q !== null); + + if (!rankedParticipantsByClass.has(classKey)) { + rankedParticipantsByClass.set(classKey, []); + } + rankedParticipantsByClass.get(classKey).push(...rankedParticipants); + } + + const qualifiers = []; + for (const [classKey, rankedParticipants] of rankedParticipantsByClass.entries()) { + const explicitQualifiers = rankedParticipants + .filter(participant => Number(participant.position || 999) <= Number(tournament.advancingPerGroup || 0)) + .sort(compareAdvancementCandidates); + + const selectedKeys = new Set(explicitQualifiers.map(participant => `${participant.isExternal ? 'E' : 'M'}:${participant.id}`)); + const desiredBracketSize = nextPowerOfTwo(explicitQualifiers.length); + + if (desiredBracketSize > explicitQualifiers.length) { + const additionalQualifiers = rankedParticipants + .filter(participant => !selectedKeys.has(`${participant.isExternal ? 'E' : 'M'}:${participant.id}`)) + .sort(compareAdvancementCandidates) + .slice(0, desiredBracketSize - explicitQualifiers.length); + + additionalQualifiers.forEach(participant => { + selectedKeys.add(`${participant.isExternal ? 'E' : 'M'}:${participant.id}`); + explicitQualifiers.push(participant); + }); + } + + explicitQualifiers.sort(compareAdvancementCandidates); + qualifiers.push(...explicitQualifiers); } return qualifiers; } @@ -3065,7 +3132,16 @@ Ve // 2. Neues Turnier anlegen }); const advancingPerGroup = t.advancingPerGroup; - let matches = buildPreferredLegacyKnockoutMatches(qualifiersByGroup, advancingPerGroup); + let matches = null; + let unusedQualifiers = []; + + const seededResult = buildSeededLegacyKnockoutMatches(classQualifiers); + if (seededResult.matches.length > 0) { + matches = seededResult.matches; + unusedQualifiers = seededResult.unusedQualifiers; + } else { + matches = buildPreferredLegacyKnockoutMatches(qualifiersByGroup, advancingPerGroup); + } if (!matches) { const groups = Object.keys(qualifiersByGroup).sort(compareQualifierGroups); @@ -3124,14 +3200,14 @@ Ve // 2. Neues Turnier anlegen } }); } + + unusedQualifiers = classQualifiers.filter(q => q && q.id && !usedQualifiers.has(q.id)); } // Falls Qualifiers übrig bleiben (ungerade Teilnehmerzahl / keine gültige Paarung möglich): // Freilos vergeben. Wir erzeugen KEIN Match mit doppelten Spielern. // Der Spieler mit Freilos wird in späteren Runden berücksichtigt, sobald dort (durch Ergebnisse) // echte Gegner feststehen. (Passt zur Vorgabe: keine Placeholder-Matches ohne bekannte Spieler.) - const usedQualifierIds = new Set(matches.flatMap(match => [match.player1?.id, match.player2?.id]).filter(id => Number.isFinite(id) && id > 0)); - const unusedQualifiers = classQualifiers.filter(q => q && q.id && !usedQualifierIds.has(q.id)); if (unusedQualifiers.length > 0) { devLog(`[startKnockout] Assigning ${unusedQualifiers.length} bye(s) for class ${classKey}:`, unusedQualifiers.map(q => q.id)); }