From 7fdbe85d3c01d45d35ce8d6a0c433dd297243f87 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sat, 28 Mar 2026 11:09:40 +0100 Subject: [PATCH] feat(TournamentService, TournamentConfigTab): enhance tournament advancement logic and knockout stage handling - Introduced a new function to compare advancement candidates based on multiple criteria, improving the selection process for tournament participants. - Updated participant data structure to include additional metrics for better ranking and comparison. - Enhanced the TournamentConfigTab to automatically configure knockout stage settings when applicable, ensuring a smoother user experience during tournament setup. --- backend/services/tournamentService.js | 79 +++++++++++++++++-- .../tournament/TournamentConfigTab.vue | 35 +++++++- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index e8272619..79b447c5 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -217,6 +217,38 @@ function nextPowerOfTwo(n) { return p; } +function compareAdvancementCandidates(a, b) { + const posA = Number(a.position || a.place || 999); + const posB = Number(b.position || b.place || 999); + if (posA !== posB) return posA - posB; + + const pointsA = Number(a.points || 0); + const pointsB = Number(b.points || 0); + if (pointsB !== pointsA) return pointsB - pointsA; + + const setDiffA = Number(a.setDiff || 0); + const setDiffB = Number(b.setDiff || 0); + if (setDiffB !== setDiffA) return setDiffB - setDiffA; + + const setsWonA = Number(a.setsWon || 0); + const setsWonB = Number(b.setsWon || 0); + if (setsWonB !== setsWonA) return setsWonB - setsWonA; + + const pointsDiffA = Number(a.pointsDiff || 0); + const pointsDiffB = Number(b.pointsDiff || 0); + if (pointsDiffB !== pointsDiffA) return pointsDiffB - pointsDiffA; + + const pointsWonA = Number(a.pointsWon || 0); + const pointsWonB = Number(b.pointsWon || 0); + if (pointsWonB !== pointsWonA) return pointsWonB - pointsWonA; + + const groupNumberA = Number(a.groupNumber || 999); + const groupNumberB = Number(b.groupNumber || 999); + if (groupNumberA !== groupNumberB) return groupNumberA - groupNumberB; + + return Number(a.id || 0) - Number(b.id || 0); +} + const THIRD_PLACE_ROUND = 'Spiel um Platz 3'; class TournamentService { /** @@ -494,6 +526,7 @@ class TournamentService { const perGroupRanked = relevantStage1Groups.map(g => ({ groupId: g.groupId, + groupNumber: g.groupNumber, classId: g.classId ?? null, // WICHTIG: // - Für interne Teilnehmer brauchen wir die ClubMember-ID (Member.id / TournamentMember.clubMemberId), @@ -502,6 +535,14 @@ class TournamentService { participants: (g.participants || []).map(p => ({ id: p.isExternal ? p.id : (p.clubMemberId ?? p.member?.id ?? p.id), isExternal: !!p.isExternal, + position: Number(p.position || 999), + points: Number(p.points || 0), + setDiff: Number(p.setDiff || 0), + setsWon: Number(p.setsWon || 0), + pointsDiff: Number(p.pointsDiff || 0), + pointsWon: Number(p.pointsWon || 0), + groupId: g.groupId, + groupNumber: g.groupNumber, })), })); @@ -722,16 +763,40 @@ class TournamentService { } } const uniqueEntrants = Array.from(seen.values()); + const selectedKeys = new Set(uniqueEntrants.map(entry => `${entry.isExternal ? 'E' : 'M'}:${entry.id}`)); + + const allRankedCandidates = perGroupRanked + .filter(group => (group.classId ?? null) === (classId ?? null)) + .flatMap(group => (group.participants || []).map(participant => ({ + id: Number(participant.id), + isExternal: !!participant.isExternal, + classId, + position: Number(participant.position || 999), + points: Number(participant.points || 0), + setDiff: Number(participant.setDiff || 0), + setsWon: Number(participant.setsWon || 0), + pointsDiff: Number(participant.pointsDiff || 0), + pointsWon: Number(participant.pointsWon || 0), + groupId: group.groupId, + groupNumber: group.groupNumber, + }))); + + const desiredBracketSize = nextPowerOfTwo(uniqueEntrants.length); + if (desiredBracketSize > uniqueEntrants.length) { + const bestAdditionalCandidates = allRankedCandidates + .filter(candidate => !selectedKeys.has(`${candidate.isExternal ? 'E' : 'M'}:${candidate.id}`)) + .sort(compareAdvancementCandidates) + .slice(0, desiredBracketSize - uniqueEntrants.length); + + bestAdditionalCandidates.forEach(candidate => { + selectedKeys.add(`${candidate.isExternal ? 'E' : 'M'}:${candidate.id}`); + uniqueEntrants.push(candidate); + }); + } const thirdPlace = wantsThirdPlace; if (uniqueEntrants.length >= 2) { - // Sortiere nach Platz: beste Plätze zuerst, dann schlechtere - // Wenn mehrere Teilnehmer den gleichen Platz haben, behalte die ursprüngliche Reihenfolge - uniqueEntrants.sort((a, b) => { - const placeA = a.place || 999; - const placeB = b.place || 999; - return placeA - placeB; - }); + uniqueEntrants.sort(compareAdvancementCandidates); // Paare: Bester gegen Schlechtesten, Zweiter gegen Vorletzten, etc. // Reverse die zweite Hälfte, um das gewünschte Pairing zu erreichen diff --git a/frontend/src/components/tournament/TournamentConfigTab.vue b/frontend/src/components/tournament/TournamentConfigTab.vue index 5dcda352..10271ebd 100644 --- a/frontend/src/components/tournament/TournamentConfigTab.vue +++ b/frontend/src/components/tournament/TournamentConfigTab.vue @@ -667,6 +667,29 @@ export default { highlightedRuleTimer: null, }; }, + watch: { + 'stageConfig.finalStageType': { + immediate: true, + handler(newValue) { + if (newValue === 'knockout' && this.stageConfig.poolsFinal.length === 0) { + this.stageConfig.poolsFinal = [{ + fromPlacesText: '1,2', + targetType: 'knockout', + targetGroupCount: 1, + }]; + } + } + }, + 'stageConfig.useIntermediateStage'() { + if (this.stageConfig.finalStageType === 'knockout' && this.stageConfig.poolsFinal.length === 0) { + this.stageConfig.poolsFinal = [{ + fromPlacesText: '1,2', + targetType: 'knockout', + targetGroupCount: 1, + }]; + } + } + }, computed: { finalPools() { return this.stageConfig.poolsFinal; @@ -1789,6 +1812,14 @@ export default { targetGroupCount: p?.target?.groupCount || this.stageConfig.finalStageGroupCount || 1, })); + if (this.stageConfig.finalStageType === 'knockout' && this.stageConfig.poolsFinal.length === 0) { + this.stageConfig.poolsFinal = [{ + fromPlacesText: '1,2', + targetType: 'knockout', + targetGroupCount: 1, + }]; + } + // KO-Flag gilt für die gesamte Endrunde: true, sobald irgendeine Final-KO-Regel thirdPlace=true hat. this.stageConfig.finalStageThirdPlace = poolsFinal.some(p => p?.target?.type === 'knockout' && p?.target?.thirdPlace === true); } catch (e) { @@ -1837,7 +1868,9 @@ export default { ? this.buildPoolsPayload(this.stageConfig.pools12, this.stageConfig.stage2GroupCount || 2, false) : []; const poolsFinal = this.buildPoolsPayload( - this.stageConfig.poolsFinal, + this.stageConfig.finalStageType === 'knockout' && this.stageConfig.poolsFinal.length === 0 + ? [{ fromPlacesText: '1,2', targetType: 'knockout', targetGroupCount: 1 }] + : this.stageConfig.poolsFinal, this.stageConfig.finalStageGroupCount || 1, true, this.stageConfig.finalStageThirdPlace === true