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.
This commit is contained in:
Torsten Schulz (local)
2026-03-28 12:15:40 +01:00
parent 2043942e02
commit 0df8674353

View File

@@ -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));
}