feat(tournament): enhance tournament configuration and results handling

- Updated TournamentConfigTab.vue to conditionally disable target type selection based on final stage type.
- Improved logic for determining target type and group count based on stage configuration.
- Refactored TournamentPlacementsTab.vue to streamline class and group placements display, including better handling of class visibility and player names.
- Enhanced TournamentResultsTab.vue to handle 'BYE' results and limit displayed entries to top three.
- Modified TournamentTab.vue to robustly determine match winners and losers, including handling 'BYE' scenarios and ensuring accurate knockout progression.
- Added logic to reset knockout matches with optional class filtering.
This commit is contained in:
Torsten Schulz (local)
2025-12-15 15:36:18 +01:00
parent 945ec0d48c
commit 047b1801b3
7 changed files with 1044 additions and 399 deletions

View File

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

View File

@@ -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 BYEMatches 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,
});
}
// BYEMatches 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 KOContainerGruppen heraus: erkenne sie über StageTypen.
// VorrundenGruppen 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

View File

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