diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js index c4df81e..e876c7b 100644 --- a/backend/controllers/tournamentController.js +++ b/backend/controllers/tournamentController.js @@ -421,9 +421,9 @@ export const reopenMatch = async (req, res) => { export const deleteKnockoutMatches = async (req, res) => { const { authcode: token } = req.headers; - const { clubId, tournamentId } = req.body; + const { clubId, tournamentId, classId } = req.body; try { - await tournamentService.resetKnockout(token, clubId, tournamentId); + await tournamentService.resetKnockout(token, clubId, tournamentId, classId); // Emit Socket-Event emitTournamentChanged(clubId, tournamentId); res.status(200).json({ message: "K.o.-Runde gelöscht" }); diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 36a11ec..b02af6f 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -251,7 +251,14 @@ class TournamentService { const perGroupRanked = relevantStage1Groups.map(g => ({ groupId: g.groupId, classId: g.classId ?? null, - participants: (g.participants || []).map(p => ({ id: p.id, isExternal: !!p.isExternal })), + // WICHTIG: + // - Für interne Teilnehmer brauchen wir die ClubMember-ID (Member.id / TournamentMember.clubMemberId), + // nicht die TournamentMember.id. + // - Für externe Teilnehmer ist `id` die ExternalTournamentParticipant.id (bestehende Logik). + participants: (g.participants || []).map(p => ({ + id: p.isExternal ? p.id : (p.clubMemberId ?? p.member?.id ?? p.id), + isExternal: !!p.isExternal, + })), })); const getByPlace = (grp, place) => grp.participants[place - 1]; @@ -262,14 +269,26 @@ class TournamentService { if (fromPlaces.length === 0) continue; const target = rule.target || {}; - const items = []; - for (const grp of perGroupRanked) { - for (const place of fromPlaces) { - const p = getByPlace(grp, Number(place)); - if (p) items.push({ ...p, classId: grp.classId ?? null }); + // Wenn Klassen verwendet werden, sollen Advancements je Klasse unabhängig sein. + // D.h. eine Pool-Regel wird pro Klasse ausgewertet (oder nur für classless, falls keine Klasse). + const classIdsInStage1 = [...new Set(perGroupRanked.map(g => (g.classId ?? null)))]; + + for (const classId of classIdsInStage1) { + const items = []; + for (const grp of perGroupRanked) { + if ((grp.classId ?? null) !== classId) continue; + for (const place of fromPlaces) { + const p = getByPlace(grp, Number(place)); + if (p) items.push({ ...p, classId }); + } + } + + // Keine leeren Regeln weiterreichen: verhindert, dass eine Klasse ohne fertige Gruppen + // "nichts" erzeugt und dadurch andere Regeln/klassen beeinflusst. + if (items.length > 0) { + poolItems.push({ target, items, classId }); } } - poolItems.push({ target, items }); } const createdGroups = []; @@ -278,6 +297,7 @@ class TournamentService { for (const pool of poolItems) { const target = pool.target || {}; const items = pool.items || []; + const poolClassId = pool.classId ?? null; if (items.length === 0) continue; if (target.type === 'groups') { @@ -287,7 +307,7 @@ class TournamentService { poolGroups.push(await TournamentGroup.create({ tournamentId, stageId: toStage.id, - classId: null, + classId: poolClassId, })); } @@ -297,27 +317,10 @@ class TournamentService { [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } - const per = Math.floor(shuffled.length / groupCount); - const rem = shuffled.length % groupCount; - let idx = 0; - for (let gIdx = 0; gIdx < groupCount; gIdx++) { - const take = per + (gIdx < rem ? 1 : 0); - for (let k = 0; k < take; k++) { - const p = shuffled[idx++]; - if (!p) continue; - if (p.isExternal) { - await ExternalTournamentParticipant.update( - { groupId: poolGroups[gIdx].id }, - { where: { id: p.id, tournamentId } } - ); - } else { - await TournamentMember.update( - { groupId: poolGroups[gIdx].id }, - { where: { id: p.id, tournamentId } } - ); - } - } - } + // WICHTIG: Um bestehende Gruppen (Stage 1) nicht zu überschreiben, + // nehmen wir hier KEINE groupId-Zuordnung für Teilnehmer vor. + // Die Stage-2-Gruppen werden angelegt und durch Matches befüllt, + // ohne den groupId-Wert der Teilnehmer zu verändern. createdGroups.push(...poolGroups); } else if (target.type === 'knockout') { @@ -332,11 +335,12 @@ class TournamentService { const containerGroup = await TournamentGroup.create({ tournamentId, stageId: toStage.id, - classId: null, + classId: poolClassId, }); createdGroups.push(containerGroup); const entrants = items.map(p => ({ + // p.id wurde oben bereits als clubMemberId (intern) bzw. ExternalParticipant.id (extern) gemappt id: Number(p.id), isExternal: !!p.isExternal, })); @@ -348,8 +352,6 @@ class TournamentService { shuffleInPlace(entrants); const bracketSize = nextPowerOfTwo(entrants.length); const byes = bracketSize - entrants.length; - for (let i = 0; i < byes; i++) entrants.push(null); - const roundName = getRoundName(bracketSize); if (wantsThirdPlace && bracketSize >= 4) { // Platzhalter-Match; Teilnehmer werden später nach den Halbfinals gesetzt. @@ -357,7 +359,7 @@ class TournamentService { tournamentId, stageId: toStage.id, groupId: containerGroup.id, - classId: null, + classId: poolClassId, groupRound: null, round: THIRD_PLACE_ROUND, player1Id: null, @@ -367,21 +369,38 @@ class TournamentService { result: null, }); } - for (let i = 0; i < entrants.length; i += 2) { - const a = entrants[i]; - const b = entrants[i + 1]; - - // TODO: Byes automatisch weitertragen (V1: Match wird nicht angelegt, wenn einer fehlt) + // Erzeuge zunächst BYE‑Matches für die ersten 'byes' Teilnehmer + let remaining = [...entrants]; + if (byes > 0) { + const byePlayers = remaining.slice(0, Math.min(byes, remaining.length)); + remaining = remaining.slice(byePlayers.length); + for (const p of byePlayers) { + if (!p) continue; + await TournamentMatch.create({ + tournamentId, + stageId: toStage.id, + groupId: containerGroup.id, + classId: poolClassId, + groupRound: null, + round: roundName, + player1Id: Number(p.id), + player2Id: null, + isFinished: true, + isActive: false, + result: 'BYE', + }); + } + } + // Paare die verbleibenden Teilnehmer normal + for (let i = 0; i < remaining.length; i += 2) { + const a = remaining[i]; + const b = remaining[i + 1]; if (!a || !b) continue; - - // Achtung: TournamentMatch kann nur INTEGER player1Id/player2Id. - // Externals und Members können kollidierende IDs haben; das ist ein Bestehendes Problem. - // V1: wir schreiben die IDs trotzdem, wie im Gruppenspiel-Teil heute (int-only). await TournamentMatch.create({ tournamentId, stageId: toStage.id, groupId: containerGroup.id, - classId: null, + classId: poolClassId, groupRound: null, round: roundName, player1Id: Number(a.id), @@ -396,70 +415,110 @@ class TournamentService { // KO als "ein einziges Feld" über alle Regeln if (singleFieldKoItems.length > 0) { - const containerGroup = await TournamentGroup.create({ - tournamentId, - stageId: toStage.id, - classId: null, - }); - createdGroups.push(containerGroup); - - const entrants = singleFieldKoItems.map(p => ({ - id: Number(p.id), - isExternal: !!p.isExternal, - })); - - // Dedupliziere (falls jemand in mehreren Regeln landet) - const seen = new Set(); - const uniqueEntrants = []; - for (const e of entrants) { - const key = `${e.isExternal ? 'E' : 'M'}:${e.id}`; - if (seen.has(key)) continue; - seen.add(key); - uniqueEntrants.push(e); + // singleField: bisher war das "alle Regeln zu einem KO-Feld mischen". + // Mit Klassen ist es praktisch, pro Klasse ein eigenes singleField-KO zu bauen. + const itemsByClass = {}; + for (const it of singleFieldKoItems) { + const key = (it.classId ?? null) === null ? 'null' : String(it.classId); + (itemsByClass[key] ||= []).push(it); } - const thirdPlace = wantsThirdPlace; - if (uniqueEntrants.length >= 2) { - shuffleInPlace(uniqueEntrants); - const bracketSize = nextPowerOfTwo(uniqueEntrants.length); - const byes = bracketSize - uniqueEntrants.length; - for (let i = 0; i < byes; i++) uniqueEntrants.push(null); + for (const [classKey, classItems] of Object.entries(itemsByClass)) { + const classId = classKey === 'null' ? null : Number(classKey); - const roundName = getRoundName(bracketSize); - if (thirdPlace && bracketSize >= 4) { - await TournamentMatch.create({ - tournamentId, - stageId: toStage.id, - groupId: containerGroup.id, - classId: null, - groupRound: null, - round: THIRD_PLACE_ROUND, - player1Id: null, - player2Id: null, - isFinished: false, - isActive: false, - result: null, - }); + const containerGroup = await TournamentGroup.create({ + tournamentId, + stageId: toStage.id, + classId, + }); + createdGroups.push(containerGroup); + + const entrants = classItems.map(p => ({ + id: Number(p.id), + isExternal: !!p.isExternal, + })); + + // Dedupliziere (falls jemand in mehreren Regeln landet) + const seen = new Set(); + const uniqueEntrants = []; + for (const e of entrants) { + const key = `${e.isExternal ? 'E' : 'M'}:${e.id}`; + if (seen.has(key)) continue; + seen.add(key); + uniqueEntrants.push(e); } - for (let i = 0; i < uniqueEntrants.length; i += 2) { - const a = uniqueEntrants[i]; - const b = uniqueEntrants[i + 1]; - if (!a || !b) continue; - await TournamentMatch.create({ - tournamentId, - stageId: toStage.id, - groupId: containerGroup.id, - classId: null, - groupRound: null, - round: roundName, - player1Id: Number(a.id), - player2Id: Number(b.id), - isFinished: false, - isActive: true, - result: null, - }); + + const thirdPlace = wantsThirdPlace; + if (uniqueEntrants.length >= 2) { + shuffleInPlace(uniqueEntrants); + const bracketSize = nextPowerOfTwo(uniqueEntrants.length); + const byes = bracketSize - uniqueEntrants.length; + + const roundName = getRoundName(bracketSize); + if (thirdPlace && bracketSize >= 4) { + await TournamentMatch.create({ + tournamentId, + stageId: toStage.id, + groupId: containerGroup.id, + classId, + groupRound: null, + round: THIRD_PLACE_ROUND, + player1Id: null, + player2Id: null, + isFinished: false, + isActive: false, + result: null, + }); + } + // BYE‑Matches zuerst + let remaining = [...uniqueEntrants]; + if (byes > 0) { + const byePlayers = remaining.slice(0, Math.min(byes, remaining.length)); + remaining = remaining.slice(byePlayers.length); + for (const p of byePlayers) { + if (!p) continue; + await TournamentMatch.create({ + tournamentId, + stageId: toStage.id, + groupId: containerGroup.id, + classId, + groupRound: null, + round: roundName, + player1Id: Number(p.id), + player2Id: null, + isFinished: true, + isActive: false, + result: 'BYE', + }); + } + } + // Verbleibende normal paaren + for (let i = 0; i < remaining.length; i += 2) { + const a = remaining[i]; + const b = remaining[i + 1]; + if (!a || !b) continue; + await TournamentMatch.create({ + tournamentId, + stageId: toStage.id, + groupId: containerGroup.id, + classId, + groupRound: null, + round: roundName, + player1Id: Number(a.id), + player2Id: Number(b.id), + isFinished: false, + isActive: true, + result: null, + }); + } } } + + return { + fromStageId: fromStage.id, + toStageId: toStage.id, + createdGroupIds: createdGroups.map(g => g.id), + }; } return { @@ -708,20 +767,40 @@ class TournamentService { throw new Error('Turnier nicht gefunden'); } - // Lösche alle bestehenden Gruppen - await TournamentGroup.destroy({ where: { tournamentId } }); + // Nur die angegebenen Klassen anpassen; keine globale Löschung + for (const [classIdStr, numberOfGroups] of Object.entries(groupsPerClass || {})) { + const classId = (classIdStr === 'null' || classIdStr === 'undefined') ? null : parseInt(classIdStr); + const desired = Number.parseInt(numberOfGroups); + if (!Number.isFinite(desired) || desired < 0) { + // Überspringe ungültige Werte + continue; + } - // Erstelle Gruppen pro Klasse - for (const [classIdStr, numberOfGroups] of Object.entries(groupsPerClass)) { - const classId = classIdStr === 'null' || classIdStr === 'undefined' ? null : parseInt(classIdStr); - const numGroups = parseInt(numberOfGroups) || 0; - - if (numGroups > 0) { - for (let i = 0; i < numGroups; i++) { - await TournamentGroup.create({ - tournamentId, - classId: classId || null - }); + // Hole bestehende Gruppen für diese Klasse + const where = { tournamentId }; + if (classId === null) { + where.classId = null; + } else { + where.classId = classId; + } + const existing = await TournamentGroup.findAll({ where, order: [['id', 'ASC']] }); + + // Wenn desired == 0: lösche nur Gruppen dieser Klasse, nicht die anderen + if (desired === 0) { + if (existing.length > 0) { + await Promise.all(existing.map(g => g.destroy())); + } + continue; + } + + // Ansonsten auf gewünschte Anzahl bringen + if (existing.length > desired) { + const toRemove = existing.slice(desired); + await Promise.all(toRemove.map(g => g.destroy())); + } else if (existing.length < desired) { + const toCreate = desired - existing.length; + for (let i = 0; i < toCreate; i++) { + await TournamentGroup.create({ tournamentId, classId }); } } } @@ -1217,7 +1296,7 @@ class TournamentService { if (!tournament || tournament.clubId != clubId) { throw new Error('Turnier nicht gefunden'); } - const groups = await TournamentGroup.findAll({ + let groups = await TournamentGroup.findAll({ where: { tournamentId }, include: [{ model: TournamentMember, @@ -1236,10 +1315,16 @@ class TournamentService { // Lade alle Gruppen-Matches mit Results für Rankings const groupMatches = await TournamentMatch.findAll({ - where: { tournamentId, round: 'group', isFinished: true }, + where: { tournamentId, round: 'group' }, include: [{ model: TournamentResult, as: 'tournamentResults' }] }); + // Filtere nur KO‑Container‑Gruppen heraus: erkenne sie über Stage‑Typen. + // Vorrunden‑Gruppen bleiben sichtbar, auch wenn (noch) keine Matches existieren. + const stages = await TournamentStage.findAll({ where: { tournamentId } }); + const knockoutStageIds = new Set(stages.filter(s => s.type === 'knockout').map(s => s.id)); + groups = groups.filter(g => !(g.stageId && knockoutStageIds.has(g.stageId))); + // Gruppiere nach Klassen und nummeriere Gruppen pro Klasse const groupsByClass = {}; groups.forEach(g => { @@ -1398,6 +1483,8 @@ class TournamentService { } else { // Bei Einzel: Normale Logik if (!stats[m.player1Id] || !stats[m.player2Id]) continue; + // Ergebnis kann null/undefiniert oder in anderem Format sein -> defensiv prüfen + if (!m.result || typeof m.result !== 'string' || !m.result.includes(':')) continue; const [s1, s2] = m.result.split(':').map(n => parseInt(n, 10)); if (s1 > s2) { @@ -1460,11 +1547,13 @@ class TournamentService { return m.groupId === g.id && ((aPlayer1 && bPlayer2) || (aPlayer2 && bPlayer1)); }); if (directMatch) { - const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10)); - const aPlayer1 = a.player1Id === directMatch.player1Id || a.player2Id === directMatch.player1Id; - const aWon = aPlayer1 ? (s1 > s2) : (s2 > s1); - if (aWon) return -1; - return 1; + if (directMatch.result && typeof directMatch.result === 'string' && directMatch.result.includes(':')) { + const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10)); + const aPlayer1 = a.player1Id === directMatch.player1Id || a.player2Id === directMatch.player1Id; + const aWon = aPlayer1 ? (s1 > s2) : (s2 > s1); + if (aWon) return -1; + return 1; + } } } else if (!isDoubles) { directMatch = groupMatches.find(m => @@ -1473,11 +1562,13 @@ class TournamentService { (m.player1Id === b.id && m.player2Id === a.id)) ); if (directMatch) { - const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10)); - const aWon = (directMatch.player1Id === a.id && s1 > s2) || - (directMatch.player2Id === a.id && s2 > s1); - if (aWon) return -1; // a hat gewonnen -> a kommt weiter oben - return 1; // b hat gewonnen -> b kommt weiter oben + if (directMatch.result && typeof directMatch.result === 'string' && directMatch.result.includes(':')) { + const [s1, s2] = directMatch.result.split(':').map(n => parseInt(n, 10)); + const aWon = (directMatch.player1Id === a.id && s1 > s2) || + (directMatch.player2Id === a.id && s2 > s1); + if (aWon) return -1; // a hat gewonnen -> a kommt weiter oben + return 1; // b hat gewonnen -> b kommt weiter oben + } } } // Fallback: Alphabetisch nach Name @@ -1516,7 +1607,9 @@ class TournamentService { (m.player1Id === p.id && m.player2Id === prev.id)) ); } - if (!directMatch || directMatch.result.split(':').map(n => +n)[0] === directMatch.result.split(':').map(n => +n)[1]) { + if (!directMatch || + !directMatch.result || typeof directMatch.result !== 'string' || !directMatch.result.includes(':') || + directMatch.result.split(':').map(n => +n)[0] === directMatch.result.split(':').map(n => +n)[1]) { // Gleicher Platz wie Vorgänger (unentschieden oder kein direktes Match) return { ...p, @@ -1830,13 +1923,22 @@ class TournamentService { // Gruppiere nach Klasse const winnersByClass = {}; sameRound.forEach(m => { - const [w1, w2] = m.result.split(":").map(n => +n); - const winner = w1 > w2 ? m.player1Id : m.player2Id; - const classKey = m.classId || 'null'; - if (!winnersByClass[classKey]) { - winnersByClass[classKey] = []; + if (!m || !m.result) return; + let winnerId = null; + // BYE: Spieler1 ist automatisch Sieger + if (String(m.result).toUpperCase() === 'BYE') { + winnerId = Number(m.player1Id) || null; + } else if (m.result.includes(':')) { + const parts = m.result.split(':'); + const w1 = Number(parts[0]); + const w2 = Number(parts[1]); + if (Number.isFinite(w1) && Number.isFinite(w2)) { + winnerId = w1 > w2 ? m.player1Id : m.player2Id; + } } - winnersByClass[classKey].push(winner); + if (!winnerId) return; + const classKey = m.classId || 'null'; + (winnersByClass[classKey] ||= []).push(Number(winnerId)); }); const nextName = nextRoundName(match.round); @@ -1848,19 +1950,24 @@ class TournamentService { // Erstelle nächste Runde pro Klasse for (const [classKey, winners] of Object.entries(winnersByClass)) { - if (winners.length < 2) continue; // Überspringe Klassen mit weniger als 2 Gewinnern - - const classId = classKey !== 'null' ? parseInt(classKey) : null; + const filtered = (winners || []).filter(id => Number.isFinite(id) && id > 0); + if (filtered.length < 2) continue; // zu wenige Gewinner - // (keine Drittplatz-Erzeugung hier) - - for (let i = 0; i < winners.length / 2; i++) { + const classId = classKey !== 'null' ? parseInt(classKey) : null; + // Paarungen sequentiell (0-1, 2-3, ...) statt außen-innen, um Ordnung zu bewahren + for (let i = 0; i + 1 < filtered.length; i += 2) { + const p1 = filtered[i]; + const p2 = filtered[i + 1]; + if (!p1 || !p2 || p1 === p2) { + devLog(`[finishMatch] Skip invalid next-round pairing in ${nextName} for class ${classKey}: ${p1} vs ${p2}`); + continue; + } await TournamentMatch.create({ tournamentId, round: nextName, - player1Id: winners[i], - player2Id: winners[winners.length - 1 - i], - classId: classId + player1Id: p1, + player2Id: p2, + classId }); } } @@ -2038,6 +2145,40 @@ class TournamentService { const rn = getRoundName(roundSize); const classId = classKey !== 'null' ? parseInt(classKey) : null; + // Sonderfall: 3 Qualifier => genau EIN "Halbfinale (3)"-Match + 1 Freilos. + // Die generische Gruppen-Pairing-Logik versucht sonst ggf. 2 Matches zu erstellen. + if (roundSize === 3) { + // Deterministisch: Darf nicht von Gruppen abhängen. + // Bye = bester Qualifier (Position 1 zuerst, dann kleinere ID als stabiler Tiebreaker) + const sorted = [...classQualifiers].sort((a, b) => { + const pa = Number(a.position ?? 999); + const pb = Number(b.position ?? 999); + if (pa !== pb) return pa - pb; + return Number(a.id) - Number(b.id); + }); + + const bye = sorted[0]; + const p1 = sorted[1]; + const p2 = sorted[2]; + + devLog(`[startKnockout] roundSize=3: assigning bye=${bye?.id} and creating 1 match ${p1?.id} vs ${p2?.id} for class ${classKey}`); + + if (p1?.id && p2?.id && p1.id !== p2.id) { + await TournamentMatch.create({ + tournamentId, + round: rn, + player1Id: p1.id, + player2Id: p2.id, + classId: classId + }); + } else { + devLog(`[startKnockout] Warning: Could not create Halbfinale (3) match for class ${classKey}`); + } + + // Bye wird nicht als Match persistiert (keine Placeholders). + continue; + } + // Drittplatz wird erst nach beiden Halbfinals mit fixen Spielern erzeugt. // Gruppiere Qualifiers nach Gruppen @@ -2120,6 +2261,15 @@ class TournamentService { } }); } + + // 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 unusedQualifiers = classQualifiers.filter(q => q && q.id && !usedQualifiers.has(q.id)); + if (unusedQualifiers.length > 0) { + devLog(`[startKnockout] Assigning ${unusedQualifiers.length} bye(s) for class ${classKey}:`, unusedQualifiers.map(q => q.id)); + } // Erstelle die Matches in der Datenbank for (const match of matches) { @@ -2127,6 +2277,12 @@ class TournamentService { devLog(`[startKnockout] Warning: Invalid match pair for class ${classKey}`); continue; } + + // Safety: niemals Spieler gegen sich selbst. + if (match.player1.id === match.player2.id) { + devLog(`[startKnockout] Warning: Prevented self-match for class ${classKey}: ${match.player1.id}`); + continue; + } try { await TournamentMatch.create({ @@ -2459,15 +2615,17 @@ class TournamentService { await match.save(); } - async resetKnockout(userToken, clubId, tournamentId) { + async resetKnockout(userToken, clubId, tournamentId, classId = null) { await checkAccess(userToken, clubId); // lösche alle Matches außer Gruppenphase - await TournamentMatch.destroy({ - where: { - tournamentId, - round: { [Op.ne]: "group" } - } - }); + const where = { + tournamentId, + round: { [Op.ne]: "group" } + }; + if (classId != null) { + where.classId = Number(classId); + } + await TournamentMatch.destroy({ where }); } // Externe Teilnehmer hinzufügen diff --git a/backend/tests/tournamentService.test.js b/backend/tests/tournamentService.test.js index 4ed108f..43a7e46 100644 --- a/backend/tests/tournamentService.test.js +++ b/backend/tests/tournamentService.test.js @@ -17,7 +17,9 @@ import Tournament from '../models/Tournament.js'; import TournamentGroup from '../models/TournamentGroup.js'; import TournamentMember from '../models/TournamentMember.js'; import TournamentMatch from '../models/TournamentMatch.js'; +import TournamentResult from '../models/TournamentResult.js'; import TournamentStage from '../models/TournamentStage.js'; +import TournamentClass from '../models/TournamentClass.js'; import Club from '../models/Club.js'; import { createMember } from './utils/factories.js'; @@ -254,6 +256,112 @@ describe('tournamentService', () => { expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id); }); + it('Stage-KO: 3 Gruppen × Plätze 1,2 => 6 Qualifier (keine falschen IDs, keine Duplikate)', async () => { + const club = await Club.create({ name: 'Tournament Club' }); + const tournament = await tournamentService.addTournament('token', club.id, 'Stage-KO-6', '2025-11-20'); + + // Stages: Vorrunde (Groups) -> Endrunde (KO) + await tournamentService.upsertTournamentStages( + 'token', + club.id, + tournament.id, + [ + { index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 3 }, + { index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null }, + ], + null, + [ + { + fromStageIndex: 1, + toStageIndex: 2, + mode: 'pools', + config: { + pools: [ + { + fromPlaces: [1, 2], + target: { type: 'knockout', singleField: true, thirdPlace: false }, + }, + ], + }, + }, + ] + ); + + // 3 Gruppen anlegen + await tournamentService.createGroups('token', club.id, tournament.id, 3); + const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] }); + expect(groups).toHaveLength(3); + + // Je Gruppe 2 Teilnehmer -> insgesamt 6 + const members = []; + for (let i = 0; i < 6; i++) { + members.push( + await createMember(club.id, { + firstName: `S${i}`, + lastName: 'KO', + email: `stage_ko6_${i}@example.com`, + gender: i % 2 === 0 ? 'male' : 'female', + }) + ); + } + // Gruppe 1 + await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[0].id, classId: null, groupId: groups[0].id }); + await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[1].id, classId: null, groupId: groups[0].id }); + // Gruppe 2 + await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[2].id, classId: null, groupId: groups[1].id }); + await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[3].id, classId: null, groupId: groups[1].id }); + // Gruppe 3 + await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[4].id, classId: null, groupId: groups[2].id }); + await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: members[5].id, classId: null, groupId: groups[2].id }); + + // Gruppenspiele erzeugen+beenden (damit Ranking/Platz 1/2 stabil ist) + // Wir erzeugen minimal pro Gruppe ein 1v1-Match und schließen es ab. + for (const g of groups) { + const [tm1, tm2] = await TournamentMember.findAll({ where: { tournamentId: tournament.id, groupId: g.id }, order: [['id', 'ASC']] }); + const gm = await TournamentMatch.create({ + tournamentId: tournament.id, + round: 'group', + groupId: g.id, + classId: null, + player1Id: tm1.id, + player2Id: tm2.id, + isFinished: true, + isActive: true, + result: '3:0', + }); + await TournamentResult.bulkCreate([ + { matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 1 }, + { matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 2 }, + { matchId: gm.id, pointsPlayer1: 11, pointsPlayer2: 1, setNumber: 3 }, + ]); + } + + // KO-Endrunde erstellen + await tournamentService.advanceTournamentStage('token', club.id, tournament.id, 1, 2); + + const stage2 = await TournamentStage.findOne({ where: { tournamentId: tournament.id, index: 2 } }); + expect(stage2).toBeTruthy(); + + const stage2Matches = await TournamentMatch.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id }, order: [['id', 'ASC']] }); + const round1 = stage2Matches.filter(m => String(m.round || '').includes('Viertelfinale') || String(m.round || '').includes('Achtelfinale') || String(m.round || '').includes('Halbfinale (3)')); + + // Bei 6 Entrants muss ein 8er-Bracket entstehen => 3 Matches in der ersten Runde. + // (Die Byes werden nicht als Matches angelegt.) + expect(round1.length).toBe(3); + for (const m of round1) { + expect(m.player1Id).toBeTruthy(); + expect(m.player2Id).toBeTruthy(); + expect(m.player1Id).not.toBe(m.player2Id); + } + + // Spieler-IDs müssen Member-IDs (clubMemberId) sein, nicht TournamentMember.id + const memberIdSet = new Set(members.map(x => x.id)); + for (const m of round1) { + expect(memberIdSet.has(m.player1Id)).toBe(true); + expect(memberIdSet.has(m.player2Id)).toBe(true); + } + }); + it('Legacy-KO: legt Platz-3 an und befüllt es nach beiden Halbfinals', async () => { const club = await Club.create({ name: 'Tournament Club' }); const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-3rd', '2025-11-15'); @@ -324,6 +432,204 @@ describe('tournamentService', () => { expect(thirdAfter.player1Id).not.toBe(thirdAfter.player2Id); }); + it('Legacy-KO: bei ungerader Qualifier-Zahl wird ein Freilos vergeben (kein Duplikat / kein Self-Match)', async () => { + const club = await Club.create({ name: 'Tournament Club' }); + const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-Bye', '2025-11-17'); + + // 3 Gruppen, jeweils 1 Spieler -> advancingPerGroup=1 => 3 Qualifier + await tournamentService.setModus('token', club.id, tournament.id, 'groups', 3, 1); + await tournamentService.createGroups('token', club.id, tournament.id, 3); + + const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] }); + expect(groups).toHaveLength(3); + + const members = []; + for (let i = 0; i < 3; i++) { + members.push( + await createMember(club.id, { + firstName: `B${i}`, + lastName: 'YE', + email: `legacy_bye_${i}@example.com`, + gender: i % 2 === 0 ? 'male' : 'female', + }) + ); + } + + // Je Gruppe genau 1 Teilnehmer, und keine Gruppenspiele nötig (es gibt keine Paarungen) + await TournamentMember.create({ + tournamentId: tournament.id, + clubMemberId: members[0].id, + classId: null, + groupId: groups[0].id, + }); + await TournamentMember.create({ + tournamentId: tournament.id, + clubMemberId: members[1].id, + classId: null, + groupId: groups[1].id, + }); + await TournamentMember.create({ + tournamentId: tournament.id, + clubMemberId: members[2].id, + classId: null, + groupId: groups[2].id, + }); + + // KO starten: Erwartung = genau 1 Match (2 Spieler) + 1 Freilos (ohne extra Match) + await tournamentService.startKnockout('token', club.id, tournament.id); + + const koMatches = await TournamentMatch.findAll({ + where: { tournamentId: tournament.id, round: { [Op.ne]: 'group' } }, + order: [['id', 'ASC']], + }); + + // Bei 3 Qualifiern muss GENAU EIN Halbfinale (3) existieren. + const semi3 = koMatches.filter(m => m.round === 'Halbfinale (3)'); + expect(semi3).toHaveLength(1); + expect(semi3[0].player1Id).toBeTruthy(); + expect(semi3[0].player2Id).toBeTruthy(); + expect(semi3[0].player1Id).not.toBe(semi3[0].player2Id); + + // Self-match darf nirgends vorkommen. + for (const m of koMatches) { + if (m.player1Id && m.player2Id) expect(m.player1Id).not.toBe(m.player2Id); + } + + // Hinweis: Bei 3 Qualifiern wird im Legacy-Flow aktuell ein "Halbfinale (3)" erzeugt. + // Ein automatisches Weitertragen des Freiloses bis in ein fertiges Finale ist nicht Teil dieses Tests. + // Wichtig ist hier die Regression: kein Duplikat und kein Self-Match. + + // Halbfinale beenden (soll keine kaputten Folge-Matches erzeugen) + await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 1, '11:1'); + await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 2, '11:1'); + await tournamentService.addMatchResult('token', club.id, tournament.id, semi3[0].id, 3, '11:1'); + + const after = await TournamentMatch.findAll({ + where: { tournamentId: tournament.id, round: { [Op.ne]: 'group' } }, + order: [['id', 'ASC']], + }); + + // Egal ob ein Folge-Match entsteht oder nicht: es darf kein Self-Match geben. + for (const m of after) { + if (m.player1Id && m.player2Id) expect(m.player1Id).not.toBe(m.player2Id); + } + }); + + it('Stage advancement ist klassenisoliert (Zwischen-/Endrunde hängt nur von der jeweiligen Klasse ab)', async () => { + const club = await Club.create({ name: 'Club', accessToken: 'token' }); + const tournament = await Tournament.create({ + clubId: club.id, + name: 'Stages Multi-Class', + date: '2025-12-14', + type: 'groups', + numberOfGroups: 2, + advancingPerGroup: 1, + winningSets: 3, + allowsExternal: false, + }); + + const classA = await TournamentClass.create({ tournamentId: tournament.id, name: 'A' }); + const classB = await TournamentClass.create({ tournamentId: tournament.id, name: 'B' }); + + await tournamentService.upsertTournamentStages( + 'token', + club.id, + tournament.id, + [ + { index: 1, type: 'groups', name: 'Vorrunde', numberOfGroups: 2 }, + { index: 2, type: 'knockout', name: 'Endrunde', numberOfGroups: null }, + ], + null, + [ + { + fromStageIndex: 1, + toStageIndex: 2, + mode: 'pools', + config: { + pools: [ + { fromPlaces: [1], target: { type: 'knockout', singleField: true, thirdPlace: false } }, + ], + }, + }, + ] + ); + + await tournamentService.createGroups('token', club.id, tournament.id, 2); + const groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id }, order: [['id', 'ASC']] }); + expect(groups.length).toBe(2); + + // Klasse A fertig + const memberA1 = await createMember(club.id, { + firstName: 'A1', + lastName: 'Test', + email: 'stage_class_a1@example.com', + gender: 'male', + }); + const memberA2 = await createMember(club.id, { + firstName: 'A2', + lastName: 'Test', + email: 'stage_class_a2@example.com', + gender: 'female', + }); + const a1 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberA1.id, classId: classA.id, groupId: groups[0].id }); + const a2 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberA2.id, classId: classA.id, groupId: groups[0].id }); + const aMatch = await TournamentMatch.create({ + tournamentId: tournament.id, + round: 'group', + groupId: groups[0].id, + classId: classA.id, + player1Id: a1.id, + player2Id: a2.id, + isFinished: true, + isActive: true, + result: '3:0', + }); + await TournamentResult.bulkCreate([ + { matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 1 }, + { matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 2 }, + { matchId: aMatch.id, pointsPlayer1: 11, pointsPlayer2: 0, setNumber: 3 }, + ]); + + // Klasse B unfertig + const memberB1 = await createMember(club.id, { + firstName: 'B1', + lastName: 'Test', + email: 'stage_class_b1@example.com', + gender: 'male', + }); + const memberB2 = await createMember(club.id, { + firstName: 'B2', + lastName: 'Test', + email: 'stage_class_b2@example.com', + gender: 'female', + }); + const b1 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberB1.id, classId: classB.id, groupId: groups[1].id }); + const b2 = await TournamentMember.create({ tournamentId: tournament.id, clubMemberId: memberB2.id, classId: classB.id, groupId: groups[1].id }); + await TournamentMatch.create({ + tournamentId: tournament.id, + round: 'group', + groupId: groups[1].id, + classId: classB.id, + player1Id: b1.id, + player2Id: b2.id, + isFinished: false, + isActive: true, + result: null, + }); + + await tournamentService.advanceTournamentStage('token', club.id, tournament.id, 1, 2); + const stage2 = await TournamentStage.findOne({ where: { tournamentId: tournament.id, index: 2 } }); + expect(stage2).toBeTruthy(); + + const stage2Matches = await TournamentMatch.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id } }); + expect(stage2Matches.some(m => m.classId === classB.id)).toBe(false); + + // Und es wurden keine Stage2-Gruppen für Klasse B erzeugt. + // (classless Container-Gruppen sind möglich – entscheidend ist, dass Klasse B nicht blockiert/vermengt wird.) + const stage2Groups = await TournamentGroup.findAll({ where: { tournamentId: tournament.id, stageId: stage2.id } }); + expect(stage2Groups.some(g => g.classId === classB.id)).toBe(false); + }); + it('Legacy-KO: Platz-3 entsteht erst nach beiden Halbfinals (ohne Placeholder)', async () => { const club = await Club.create({ name: 'Tournament Club' }); const tournament = await tournamentService.addTournament('token', club.id, 'Legacy-KO-3rd-late', '2025-11-16'); diff --git a/frontend/src/components/tournament/TournamentConfigTab.vue b/frontend/src/components/tournament/TournamentConfigTab.vue index 0347b9f..c37ec63 100644 --- a/frontend/src/components/tournament/TournamentConfigTab.vue +++ b/frontend/src/components/tournament/TournamentConfigTab.vue @@ -151,12 +151,12 @@
-