Fixed tournament - groups in end round and place 3
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s

This commit is contained in:
Torsten Schulz (local)
2026-06-10 08:08:31 +02:00
parent 5423f24969
commit 8d1bce2ff9
21 changed files with 396 additions and 153 deletions

View File

@@ -124,6 +124,86 @@ function compareKnockoutEntrants(a, b) {
const THIRD_PLACE_ROUND = 'Spiel um Platz 3';
class TournamentService {
async createRoundRobinMatchesForGroups(tournamentId, groups, stageId = null) {
for (const group of groups) {
const internalMembers = await TournamentMember.findAll({ where: { groupId: group.id } });
const externalMembers = await ExternalTournamentParticipant.findAll({ where: { groupId: group.id } });
const allGroupMembers = [
...internalMembers.map(m => ({ id: m.id, key: `internal-${m.id}` })),
...externalMembers.map(m => ({ id: m.id, key: `external-${m.id}` })),
];
if (allGroupMembers.length < 2) continue;
const memberMap = new Map(allGroupMembers.map(m => [m.key, m]));
const rounds = this.generateRoundRobinSchedule(allGroupMembers.map(m => ({ id: m.key })));
for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) {
for (const [p1Key, p2Key] of rounds[roundIndex]) {
const p1 = memberMap.get(p1Key);
const p2 = memberMap.get(p2Key);
if (!p1 || !p2) continue;
await TournamentMatch.create({
tournamentId,
stageId,
groupId: group.id,
round: 'group',
player1Id: p1.id,
player2Id: p2.id,
groupRound: roundIndex + 1,
classId: group.classId,
});
}
}
}
}
async assignEntrantsToStageGroups(tournamentId, items, groups, classId) {
if (!groups.length || !items.length) return;
const orderedItems = [...items].sort(compareKnockoutEntrants);
const assignments = groups.map(() => []);
orderedItems.forEach((item, index) => {
assignments[index % groups.length].push(item);
});
for (let groupIndex = 0; groupIndex < assignments.length; groupIndex++) {
const group = groups[groupIndex];
for (const item of assignments[groupIndex]) {
if (item.isExternal) {
const original = await ExternalTournamentParticipant.findByPk(Number(item.id));
if (!original) continue;
await ExternalTournamentParticipant.create({
tournamentId,
groupId: group.id,
firstName: original.firstName,
lastName: original.lastName,
club: original.club,
email: original.email,
address: original.address,
birthDate: original.birthDate,
gender: original.gender,
seeded: original.seeded || false,
classId,
outOfCompetition: original.outOfCompetition || false,
gaveUp: original.gaveUp || false,
});
} else {
const clubMemberId = Number(item.clubMemberId ?? item.member?.id ?? item.id);
const member = Number.isFinite(clubMemberId) ? await Member.findByPk(clubMemberId) : null;
if (!member) continue;
await TournamentMember.create({
tournamentId,
groupId: group.id,
clubMemberId,
seeded: item.seeded || false,
classId,
outOfCompetition: item.outOfCompetition || false,
gaveUp: item.gaveUp || false,
});
}
}
}
}
// -------- Multi-Stage (Runden) V1 --------
async getTournamentStages(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
@@ -257,24 +337,35 @@ class TournamentService {
}
const config = normalizeJsonConfig(adv.config, 'Advancement-Konfiguration');
const pools = Array.isArray(config.pools) ? config.pools : [];
let pools = Array.isArray(config.pools) ? config.pools : [];
if (pools.length === 0) {
const keys = Object.keys(config);
throw new Error(
`Advancement-Konfiguration ist leer (keine Pools). ` +
`advancementId=${adv.id}, fromStageId=${fromStage.id}, toStageId=${toStage.id}, ` +
`configKeys=${keys.join(',') || '(none)'}`
);
const advancingCount = Math.max(1, Number(toStage.advancingPerGroup || t.advancingPerGroup || 2));
const fromPlaces = Array.from({ length: advancingCount }, (_, index) => index + 1);
pools = [{
fromPlaces,
target: toStage.type === 'knockout'
? { type: 'knockout', singleField: true }
: { type: 'groups', groupCount: Math.max(1, Number(toStage.numberOfGroups || 1)) },
}];
}
// Cleanup Stage2
// Cleanup Zielrunde inklusive dort duplizierter Teilnehmer.
const existingTargetGroups = await TournamentGroup.findAll({ where: { tournamentId, stageId: toStage.id } });
const existingTargetGroupIds = existingTargetGroups.map(g => g.id);
await TournamentMatch.destroy({ where: { tournamentId, stageId: toStage.id } });
if (existingTargetGroupIds.length > 0) {
await TournamentMember.destroy({ where: { tournamentId, groupId: { [Op.in]: existingTargetGroupIds } } });
await ExternalTournamentParticipant.destroy({ where: { tournamentId, groupId: { [Op.in]: existingTargetGroupIds } } });
}
await TournamentGroup.destroy({ where: { tournamentId, stageId: toStage.id } });
// Stage1 Gruppen mit Teilnehmern (Ranking aus existierender Logik)
const stage1Groups = await this.getGroupsWithParticipants(userToken, clubId, tournamentId);
const relevantStage1Groups = stage1Groups.filter(g => (g.stageId == null) || (g.stageId === fromStage.id));
if (relevantStage1Groups.length === 0) throw new Error('Keine Gruppen in Runde 1 gefunden');
const relevantStage1Groups = stage1Groups.filter(g => {
if (Number(fromStage.index) === 1 && g.stageId == null) return true;
return Number(g.stageId) === Number(fromStage.id);
});
if (relevantStage1Groups.length === 0) throw new Error('Keine Gruppen in der Ausgangsrunde gefunden');
const perGroupRanked = relevantStage1Groups.map(g => ({
groupId: g.groupId,
@@ -370,10 +461,8 @@ class TournamentService {
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
// 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.
await this.assignEntrantsToStageGroups(tournamentId, shuffled, poolGroups, poolClassId);
await this.createRoundRobinMatchesForGroups(tournamentId, poolGroups, toStage.id);
createdGroups.push(...poolGroups);
} else if (target.type === 'knockout') {
@@ -406,22 +495,6 @@ class TournamentService {
const bracketSize = nextPowerOfTwo(entrants.length);
const byes = bracketSize - entrants.length;
const roundName = getRoundName(bracketSize);
if (wantsThirdPlace && bracketSize >= 4) {
// Platzhalter-Match; Teilnehmer werden später nach den Halbfinals gesetzt.
await TournamentMatch.create({
tournamentId,
stageId: toStage.id,
groupId: containerGroup.id,
classId: poolClassId,
groupRound: null,
round: THIRD_PLACE_ROUND,
player1Id: null,
player2Id: null,
isFinished: false,
isActive: false,
result: null,
});
}
// Erzeuge zunächst BYEMatches für die ersten 'byes' Teilnehmer
let remaining = [...entrants];
if (byes > 0) {
@@ -540,22 +613,6 @@ class TournamentService {
const reversedSecondHalf = [...secondHalf].reverse(); // Umgekehrte Reihenfolge für Pairing
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 (für die ersten Teilnehmer, wenn nötig)
if (byes > 0) {
// Die besten Spieler bekommen BYEs
@@ -769,7 +826,12 @@ class TournamentService {
if (classId !== null) {
whereClause.classId = classId;
}
return await TournamentMember.findAll({
const stageGroups = await TournamentGroup.findAll({
where: { tournamentId, stageId: { [Op.ne]: null } },
attributes: ['id'],
});
const stageGroupIds = new Set(stageGroups.map(g => Number(g.id)));
const participants = await TournamentMember.findAll({
where: whereClause,
include: [{
model: Member,
@@ -778,6 +840,7 @@ class TournamentService {
}],
order: [[{ model: Member, as: 'member' }, 'firstName', 'ASC']]
});
return participants.filter(p => !p.groupId || !stageGroupIds.has(Number(p.groupId)));
}
// 5. Modus setzen (Gruppen / KORunde)
@@ -1545,6 +1608,7 @@ class TournamentService {
// 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 stageById = new Map(stages.map(s => [Number(s.id), s]));
const knockoutStageIds = new Set(stages.filter(s => s.type === 'knockout').map(s => s.id));
groups = groups.filter(g => !(g.stageId && knockoutStageIds.has(g.stageId)));
@@ -1634,6 +1698,7 @@ class TournamentService {
for (const tm of g.tournamentGroupMembers || []) {
stats[tm.id] = {
id: tm.id,
clubMemberId: tm.clubMemberId,
name: `${tm.member.firstName} ${tm.member.lastName}`,
seeded: tm.seeded || false,
isExternal: false,
@@ -1870,10 +1935,22 @@ class TournamentService {
};
});
const stage = g.stageId ? stageById.get(Number(g.stageId)) : null;
const sameStageGroups = classGroups.filter(other => (other.stageId ?? null) === (g.stageId ?? null));
const stageGroupIndex = sameStageGroups.findIndex(other => Number(other.id) === Number(g.id));
const groupNumber = stageGroupIndex >= 0 ? stageGroupIndex + 1 : idx + 1;
const stageLabel = stage
? (stage.name || (Number(stage.index) === 3 ? 'Endrunde' : 'Zwischenrunde'))
: null;
result.push({
groupId: g.id,
classId: g.classId,
groupNumber: idx + 1, // Nummer innerhalb der Klasse
stageId: g.stageId ?? null,
stageIndex: stage?.index ?? null,
stageName: stageLabel,
groupNumber,
groupLabel: stageLabel ? `${stageLabel}-Gruppe ${groupNumber}` : null,
participants: participantsWithPosition
});
});
@@ -2150,9 +2227,20 @@ class TournamentService {
}
});
if (!existingThirdPlace) {
if (existingThirdPlace) {
if ((existingThirdPlace.player1Id == null || existingThirdPlace.player1Id === 0) && (existingThirdPlace.player2Id == null || existingThirdPlace.player2Id === 0)) {
existingThirdPlace.player1Id = losers[0];
existingThirdPlace.player2Id = losers[1];
existingThirdPlace.stageId = existingThirdPlace.stageId || match.stageId || null;
existingThirdPlace.groupId = existingThirdPlace.groupId || match.groupId || null;
existingThirdPlace.isActive = true;
await existingThirdPlace.save();
}
} else {
await TournamentMatch.create({
tournamentId,
stageId: match.stageId || null,
groupId: match.groupId || null,
round: THIRD_PLACE_ROUND,
player1Id: losers[0],
player2Id: losers[1],
@@ -2170,7 +2258,7 @@ class TournamentService {
// Gilt nur für neue Stage-KO-Matches (stageId + groupId gesetzt).
// Wichtig: Halbfinal-Rundennamen können Suffixe haben (z.B. "Halbfinale (3)").
if (match.stageId && match.groupId && match.round && String(match.round).includes('Halbfinale')) {
const thirdPlaceMatch = await TournamentMatch.findOne({
let thirdPlaceMatch = await TournamentMatch.findOne({
where: {
tournamentId,
stageId: match.stageId,
@@ -2179,25 +2267,37 @@ class TournamentService {
}
});
if (thirdPlaceMatch) {
const semifinals = await TournamentMatch.findAll({
where: {
tournamentId,
stageId: match.stageId,
groupId: match.groupId,
}
});
const semifinals = await TournamentMatch.findAll({
where: {
tournamentId,
stageId: match.stageId,
groupId: match.groupId,
}
});
const semiMatches = semifinals.filter(m => m.round && String(m.round).includes('Halbfinale'));
const semiMatches = semifinals.filter(m => m.round && String(m.round).includes('Halbfinale'));
const finishedSemis = semiMatches.filter(m => m.isFinished && m.result);
if (finishedSemis.length >= 2) {
const losers = finishedSemis
.map(getLoserId)
.filter(id => Number.isFinite(id) && id > 0);
const finishedSemis = semiMatches.filter(m => m.isFinished && m.result);
if (finishedSemis.length >= 2) {
const losers = finishedSemis
.map(getLoserId)
.filter(id => Number.isFinite(id) && id > 0);
// Nur setzen, wenn wir genau 2 Verlierer haben und das Match noch "leer" ist.
if (losers.length === 2 && (thirdPlaceMatch.player1Id == null || thirdPlaceMatch.player1Id === 0) && (thirdPlaceMatch.player2Id == null || thirdPlaceMatch.player2Id === 0)) {
if (losers.length === 2) {
if (!thirdPlaceMatch) {
thirdPlaceMatch = await TournamentMatch.create({
tournamentId,
stageId: match.stageId,
groupId: match.groupId,
classId: match.classId,
groupRound: null,
round: THIRD_PLACE_ROUND,
player1Id: losers[0],
player2Id: losers[1],
isFinished: false,
isActive: true,
result: null,
});
} else if ((thirdPlaceMatch.player1Id == null || thirdPlaceMatch.player1Id === 0) && (thirdPlaceMatch.player2Id == null || thirdPlaceMatch.player2Id === 0)) {
thirdPlaceMatch.player1Id = losers[0];
thirdPlaceMatch.player2Id = losers[1];
thirdPlaceMatch.isActive = true;
@@ -2208,14 +2308,17 @@ class TournamentService {
}
// Prüfe, ob alle Matches dieser Runde UND Klasse abgeschlossen sind
const roundWhere = { tournamentId, round: match.round, classId: match.classId };
if (match.stageId) roundWhere.stageId = match.stageId;
if (match.groupId) roundWhere.groupId = match.groupId;
const allFinished = await TournamentMatch.count({
where: { tournamentId, round: match.round, isFinished: false, classId: match.classId }
where: { ...roundWhere, isFinished: false }
}) === 0;
if (allFinished) {
// Lade alle Matches dieser Runde UND Klasse
// Lade alle Matches dieser Runde UND Klasse innerhalb desselben KO-Felds
const sameRound = await TournamentMatch.findAll({
where: { tournamentId, round: match.round, classId: match.classId }
where: roundWhere
});
// Gruppiere nach Klasse
@@ -2262,6 +2365,8 @@ class TournamentService {
}
await TournamentMatch.create({
tournamentId,
stageId: match.stageId || null,
groupId: match.groupId || null,
round: nextName,
player1Id: p1,
player2Id: p2,
@@ -3053,15 +3158,50 @@ class TournamentService {
async resetKnockout(userToken, clubId, tournamentId, classId = null) {
await checkAccess(userToken, clubId);
// lösche alle Matches außer Gruppenphase
const where = {
const stages = await TournamentStage.findAll({ where: { tournamentId } });
const nonPreliminaryStageIds = stages
.filter(stage => Number(stage.index) !== 1)
.map(stage => Number(stage.id));
const stageGroupWhere = { tournamentId };
if (nonPreliminaryStageIds.length > 0) {
stageGroupWhere.stageId = { [Op.in]: nonPreliminaryStageIds };
} else {
stageGroupWhere.stageId = { [Op.ne]: null };
}
if (classId != null) {
stageGroupWhere.classId = Number(classId);
}
const stageGroups = await TournamentGroup.findAll({ where: stageGroupWhere });
const stageGroupIds = stageGroups.map(group => Number(group.id));
const knockoutWhere = {
tournamentId,
round: { [Op.ne]: "group" }
};
if (classId != null) {
where.classId = Number(classId);
knockoutWhere.classId = Number(classId);
}
await TournamentMatch.destroy({ where: knockoutWhere });
if (stageGroupIds.length > 0) {
const stageMatchWhere = {
tournamentId,
[Op.or]: [
{ groupId: { [Op.in]: stageGroupIds } },
{ stageId: { [Op.in]: nonPreliminaryStageIds } },
],
};
if (classId != null) {
stageMatchWhere.classId = Number(classId);
}
await TournamentMatch.destroy({ where: stageMatchWhere });
await TournamentMember.destroy({ where: { tournamentId, groupId: { [Op.in]: stageGroupIds } } });
await ExternalTournamentParticipant.destroy({ where: { tournamentId, groupId: { [Op.in]: stageGroupIds } } });
await TournamentGroup.destroy({ where: { id: { [Op.in]: stageGroupIds } } });
}
await TournamentMatch.destroy({ where });
}
// Externe Teilnehmer hinzufügen
@@ -3143,10 +3283,16 @@ class TournamentService {
if (classId !== null) {
whereClause.classId = classId;
}
return await ExternalTournamentParticipant.findAll({
const stageGroups = await TournamentGroup.findAll({
where: { tournamentId, stageId: { [Op.ne]: null } },
attributes: ['id'],
});
const stageGroupIds = new Set(stageGroups.map(g => Number(g.id)));
const participants = await ExternalTournamentParticipant.findAll({
where: whereClause,
order: [['firstName', 'ASC'], ['lastName', 'ASC']]
});
return participants.filter(p => !p.groupId || !stageGroupIds.has(Number(p.groupId)));
}
// Externe Teilnehmer löschen