finished tournaments

This commit is contained in:
Torsten Schulz
2025-07-16 14:29:34 +02:00
parent d0544da1ba
commit 4122868ab0
6 changed files with 963 additions and 137 deletions

View File

@@ -57,9 +57,9 @@ export const getParticipants = async (req, res) => {
// 5. Turniermodus (Gruppen/K.O.) setzen
export const setModus = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, type, numberOfGroups } = req.body;
const { clubId, tournamentId, type, numberOfGroups, advancingPerGroup } = req.body;
try {
await tournamentService.setModus(token, clubId, tournamentId, type, numberOfGroups);
await tournamentService.setModus(token, clubId, tournamentId, type, numberOfGroups, advancingPerGroup);
res.sendStatus(204);
} catch (error) {
console.error(error);
@@ -164,9 +164,117 @@ export const startKnockout = async (req, res) => {
try {
await tournamentService.startKnockout(token, clubId, tournamentId);
res.status(200).json({ message: 'K.O.-Runde erfolgreich gestartet' });
res.status(200).json({ message: "K.o.-Runde erfolgreich gestartet" });
} catch (error) {
console.error('Error in startKnockout:', error);
const status = /Gruppenmodus|Zu wenige Qualifikanten/.test(error.message) ? 400 : 500;
res.status(status).json({ error: error.message });
}
};
export const manualAssignGroups = async (req, res) => {
const { authcode: token } = req.headers;
const {
clubId,
tournamentId,
assignments, // [{ participantId, groupNumber }]
numberOfGroups, // optional
maxGroupSize // optional
} = req.body;
try {
const groupsWithParts = await tournamentService.manualAssignGroups(
token,
clubId,
tournamentId,
assignments,
numberOfGroups, // neu
maxGroupSize // neu
);
res.status(200).json(groupsWithParts);
} catch (error) {
console.error('Error in manualAssignGroups:', error);
res.status(500).json({ error: error.message });
}
};
export const resetGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.resetGroups(token, clubId, tournamentId);
res.sendStatus(204);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
};
export const resetMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.resetMatches(token, clubId, tournamentId);
res.sendStatus(204);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
};
export const removeParticipant = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.body;
try {
await tournamentService.removeParticipant(token, clubId, tournamentId, participantId);
const participants = await tournamentService.getParticipants(token, clubId, tournamentId);
res.status(200).json(participants);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
};
export const deleteMatchResult = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId, set } = req.body;
try {
await tournamentService.deleteMatchResult(
token,
clubId,
tournamentId,
matchId,
set
);
res.status(200).json({ message: 'Einzelsatz gelöscht' });
} catch (error) {
console.error('Error in deleteMatchResult:', error);
res.status(500).json({ error: error.message });
}
};
export const reopenMatch = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId } = req.body;
try {
await tournamentService.reopenMatch(token, clubId, tournamentId, matchId);
// Gib optional das aktualisierte Match zurück
res.status(200).json({ message: "Match reopened" });
} catch (error) {
console.error("Error in reopenMatch:", error);
res.status(500).json({ error: error.message });
}
};
export const deleteKnockoutMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.resetKnockout(token, clubId, tournamentId);
res.status(200).json({ message: "K.o.-Runde gelöscht" });
} catch (error) {
console.error("Error in deleteKnockoutMatches:", error);
res.status(500).json({ error: error.message });
}
};

View File

@@ -14,14 +14,14 @@ const Tournament = sequelize.define('Tournament', {
type: DataTypes.STRING,
allowNull: false,
},
bestOfEndroundSize: {
advancingPerGroup: {
type: DataTypes.INTEGER,
allowNull: false,
},
numberOfGroups: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
defaultValue: 0,
},
clubId: {
type: DataTypes.INTEGER,

View File

@@ -25,6 +25,10 @@ const TournamentMatch = sequelize.define('TournamentMatch', {
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
},
groupRound: {
type: DataTypes.INTEGER,
allowNull: true,
},
round: {
type: DataTypes.STRING,
allowNull: false,

View File

@@ -13,6 +13,13 @@ import {
addMatchResult,
finishMatch,
startKnockout,
manualAssignGroups,
resetGroups,
resetMatches,
removeParticipant,
deleteMatchResult,
reopenMatch,
deleteKnockoutMatches,
} from '../controllers/tournamentController.js';
import { authenticate } from '../middleware/authMiddleware.js';
@@ -20,17 +27,23 @@ const router = express.Router();
router.post('/participant', authenticate, addParticipant);
router.post('/participants', authenticate, getParticipants);
router.delete('/participant', authenticate, removeParticipant);
router.post('/modus', authenticate, setModus);
router.post('/groups/reset', authenticate, resetGroups);
router.post('/matches/reset', authenticate, resetMatches);
router.put('/groups', authenticate, createGroups);
router.post('/groups', authenticate, fillGroups);
router.get('/groups', authenticate, getGroups);
router.post('/match/result', authenticate, addMatchResult);
router.delete('/match/result', authenticate, deleteMatchResult);
router.post("/match/reopen", reopenMatch);
router.post('/match/finish', authenticate, finishMatch);
router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches);
router.get('/:clubId/:tournamentId', authenticate, getTournament);
router.get('/:clubId', authenticate, getTournaments);
router.post('/knockout', authenticate, startKnockout);
router.delete("/matches/knockout", deleteKnockoutMatches);
router.post('/groups/manual', authenticate, manualAssignGroups);
router.post('/', authenticate, addTournament);
export default router;

View File

@@ -8,6 +8,25 @@ import TournamentResult from "../models/TournamentResult.js";
import { checkAccess } from '../utils/userUtils.js';
import { Op, literal } from 'sequelize';
function getRoundName(size) {
switch (size) {
case 2: return "Finale";
case 4: return "Halbfinale";
case 8: return "Viertelfinale";
case 16: return "Achtelfinale";
default: return `Runde der ${size}`;
}
}
function nextRoundName(currentName) {
switch (currentName) {
case "Achtelfinale": return "Viertelfinale";
case "Viertelfinale": return "Halbfinale";
case "Halbfinale": return "Finale";
default: return null;
}
}
class TournamentService {
// 1. Turniere listen
async getTournaments(userToken, clubId) {
@@ -76,13 +95,13 @@ class TournamentService {
}
// 5. Modus setzen (Gruppen / KORunde)
async setModus(userToken, clubId, tournamentId, type, numberOfGroups) {
async setModus(userToken, clubId, tournamentId, type, numberOfGroups, advancingPerGroup) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
await tournament.update({ type, numberOfGroups });
await tournament.update({ type, numberOfGroups, advancingPerGroup });
}
// 6. Leere Gruppen anlegen
@@ -106,53 +125,95 @@ class TournamentService {
}
// 7. Gruppen zufällig füllen & Spiele anlegen
generateRoundRobinSchedule(players) {
const list = [...players];
const n = list.length;
const hasBye = n % 2 === 1;
if (hasBye) list.push(null); // füge Bye hinzu
const total = list.length; // jetzt gerade Zahl
const rounds = [];
for (let round = 0; round < total - 1; round++) {
const pairs = [];
for (let i = 0; i < total / 2; i++) {
const p1 = list[i];
const p2 = list[total - 1 - i];
if (p1 && p2) {
pairs.push([p1.id, p2.id]);
}
}
rounds.push(pairs);
// Rotation (Fixpunkt list[0]):
list.splice(1, 0, list.pop());
}
return rounds;
}
// services/tournamentService.js
async fillGroups(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
const groups = await TournamentGroup.findAll({ where: { tournamentId } });
// 1) Hole vorhandene Gruppen
let groups = await TournamentGroup.findAll({ where: { tournamentId } });
// **Neu**: Falls noch keine Gruppen existieren, lege sie nach numberOfGroups an
if (!groups.length) {
throw new Error('Keine Gruppen vorhanden. Erst erstellen.');
const desired = tournament.numberOfGroups || 1; // Fallback auf 1, wenn undefiniert
for (let i = 0; i < desired; i++) {
await TournamentGroup.create({ tournamentId });
}
groups = await TournamentGroup.findAll({ where: { tournamentId } });
}
const members = await TournamentMember.findAll({ where: { tournamentId } });
if (!members.length) {
throw new Error('Keine Teilnehmer vorhanden.');
}
// alte Matches löschen
// 2) Alte Matches löschen
await TournamentMatch.destroy({ where: { tournamentId } });
// mische Teilnehmer
// 3) Shuffle + verteilen
const shuffled = members.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
// verteile in roundrobinGruppen
for (let idx = 0; idx < shuffled.length; idx++) {
const grpId = groups[idx % groups.length].id;
await shuffled[idx].update({ groupId: grpId });
}
// lege alle Paarungen in jeder Gruppe an
groups.forEach((g, idx) => {
shuffled
.filter((_, i) => i % groups.length === idx)
.forEach(m => m.update({ groupId: g.id }));
});
// 4) RoundRobin anlegen wie gehabt
for (const g of groups) {
const gm = await TournamentMember.findAll({ where: { groupId: g.id } });
for (let i = 0; i < gm.length; i++) {
for (let j = i + 1; j < gm.length; j++) {
const rounds = this.generateRoundRobinSchedule(gm);
for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) {
for (const [p1Id, p2Id] of rounds[roundIndex]) {
await TournamentMatch.create({
tournamentId,
groupId: g.id,
round: 'group',
player1Id: gm[i].id,
player2Id: gm[j].id
player1Id: p1Id,
player2Id: p2Id,
groupRound: roundIndex + 1
});
}
}
}
// 5) Teilnehmer mit Gruppen zurückgeben
return await TournamentMember.findAll({ where: { tournamentId } });
}
// 8. Nur Gruppen (ohne Teilnehmer)
async getGroups(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
@@ -163,6 +224,8 @@ class TournamentService {
}
// 9. Gruppen mit ihren Teilnehmern
// services/tournamentService.js
async getGroupsWithParticipants(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
@@ -178,8 +241,11 @@ class TournamentService {
}],
order: [['id', 'ASC']]
});
return groups.map(g => ({
// hier den Index mit aufnehmen:
return groups.map((g, idx) => ({
groupId: g.id,
groupNumber: idx + 1, // jetzt definiert
participants: g.tournamentGroupMembers.map(m => ({
id: m.id,
name: `${m.member.firstName} ${m.member.lastName}`
@@ -187,6 +253,7 @@ class TournamentService {
}));
}
// 10. Einzelnes Turnier
async getTournament(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
@@ -208,6 +275,8 @@ class TournamentService {
{ model: TournamentResult, as: 'tournamentResults' }
],
order: [
['group_id', 'ASC'],
['group_round', 'ASC'],
['id', 'ASC'],
[{ model: TournamentResult, as: 'tournamentResults' }, 'set', 'ASC']
]
@@ -230,71 +299,74 @@ class TournamentService {
}
}
// 13. Match abschließen (Endergebnis setzen)
async finishMatch(userToken, clubId, tournamentId, matchId) {
await checkAccess(userToken, clubId);
const matches = await TournamentMatch.findAll({
where: { id: matchId, tournamentId },
include: [{ model: TournamentResult, as: 'tournamentResults' }],
order: [[{ model: TournamentResult, as: 'tournamentResults' }, 'set', 'ASC']]
const match = await TournamentMatch.findByPk(matchId, {
include: [{ model: TournamentResult, as: "tournamentResults" }]
});
const match = matches[0];
if (!match) throw new Error('Match nicht gefunden');
if (!match) throw new Error("Match nicht gefunden");
let win = 0, lose = 0;
match.tournamentResults.forEach(r => {
for (const r of match.tournamentResults) {
if (r.pointsPlayer1 > r.pointsPlayer2) win++;
else lose++;
});
}
match.isFinished = true;
match.result = `${win}:${lose}`;
await match.save();
const allFinished = await TournamentMatch.count({
where: { tournamentId, round: match.round, isFinished: false }
}) === 0;
if (allFinished) {
const sameRound = await TournamentMatch.findAll({
where: { tournamentId, round: match.round }
});
const winners = sameRound.map(m => {
const [w1, w2] = m.result.split(":").map(n => +n);
return w1 > w2 ? m.player1Id : m.player2Id;
});
const nextName = nextRoundName(match.round);
if (nextName) {
for (let i = 0; i < winners.length / 2; i++) {
await TournamentMatch.create({
tournamentId,
round: nextName,
player1Id: winners[i],
player2Id: winners[winners.length - 1 - i]
});
}
}
}
}
/**
* Ermittelt aus jeder Gruppe den Gruppensieger und legt
* für die K.O.-Runde die ersten Matches an.
*/
// services/tournamentService.js
async startKnockout(token, clubId, tournamentId) {
await checkAccess(token, clubId);
const t = await Tournament.findByPk(tournamentId);
if (!t || t.clubId != clubId) throw new Error('Tournament not found');
const totalQualifiers = t.numberOfGroups * t.advancingPerGroup;
if (totalQualifiers < 2) throw new Error('Zu wenige Qualifikanten für K.O.-Runde');
// lösche frühere KO-Matches
await TournamentMatch.destroy({ where: { tournamentId, round: { [Op.ne]: 'group' } } });
// lade alle Gruppenteilnehmer
async _determineQualifiers(tournamentId, tournament) {
const groups = await TournamentGroup.findAll({
where: { tournamentId },
include: [{ model: TournamentMember, as: 'tournamentGroupMembers' }]
include: [{ model: TournamentMember, as: "tournamentGroupMembers" }]
});
// lade alle Gruppenspiele und Ergebnisse
const groupMatches = await TournamentMatch.findAll({
where: { tournamentId, round: 'group' },
include: [{ model: TournamentResult, as: 'tournamentResults' }]
where: { tournamentId, round: "group", isFinished: true }
});
const qualifiers = [];
for (const g of groups) {
// init stats
const stats = {};
g.tournamentGroupMembers.forEach(m => {
stats[m.id] = { member: m, points: 0, setsWon: 0, setsLost: 0 };
});
// auswerten
for (const m of groupMatches.filter(m => m.groupId === g.id && m.isFinished)) {
const [p1, p2] = m.result.split(':').map(n => parseInt(n, 10));
for (const tm of g.tournamentGroupMembers) {
stats[tm.id] = { member: tm, points: 0, setsWon: 0, setsLost: 0 };
}
for (const m of groupMatches.filter(m => m.groupId === g.id)) {
if (!stats[m.player1Id] || !stats[m.player2Id]) continue;
const [p1, p2] = m.result.split(":").map(n => parseInt(n, 10));
if (p1 > p2) stats[m.player1Id].points += 2;
else if (p2 > p1) stats[m.player2Id].points += 2;
else stats[m.player2Id].points += 2;
stats[m.player1Id].setsWon += p1;
stats[m.player1Id].setsLost += p2;
stats[m.player2Id].setsWon += p2;
stats[m.player2Id].setsLost += p1;
}
// sortieren
const ranked = Object.values(stats).sort((a, b) => {
const diffA = a.setsWon - a.setsLost;
const diffB = b.setsWon - b.setsLost;
@@ -303,34 +375,206 @@ class TournamentService {
if (b.setsWon !== a.setsWon) return b.setsWon - a.setsWon;
return a.member.id - b.member.id;
});
// take top N
qualifiers.push(...ranked.slice(0, t.advancingPerGroup).map(r => r.member));
qualifiers.push(...ranked.slice(0, tournament.advancingPerGroup).map(r => r.member));
}
return qualifiers;
}
async startKnockout(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const t = await Tournament.findByPk(tournamentId);
if (!t || t.clubId != clubId) throw new Error("Tournament not found");
if (t.type === "groups") {
const unfinished = await TournamentMatch.count({
where: { tournamentId, round: "group", isFinished: false }
});
if (unfinished > 0) {
throw new Error(
"Turnier ist im Gruppenmodus, K.o.-Runde kann erst nach Abschluss aller Gruppenspiele gestartet werden."
);
}
}
// bracket aufbauen
let roundSize = qualifiers.length;
const getRoundName = size => {
switch (size) {
case 2: return 'Finale';
case 4: return 'Halbfinale';
case 8: return 'Viertelfinale';
case 16: return 'Achtelfinale';
default: return `Runde der ${size}`;
}
};
const qualifiers = await this._determineQualifiers(tournamentId, t);
if (qualifiers.length < 2) throw new Error("Zu wenige Qualifikanten für K.O.-Runde");
while (roundSize >= 2) {
const rn = getRoundName(roundSize);
for (let i = 0; i < roundSize / 2; i++) {
const p1 = qualifiers[i].id;
const p2 = qualifiers[roundSize - 1 - i].id;
await TournamentMatch.create({ tournamentId, round: rn, player1Id: p1, player2Id: p2 });
}
// Platzhalter für nächste Runde
qualifiers.splice(roundSize / 2);
roundSize = roundSize / 2;
await TournamentMatch.destroy({
where: { tournamentId, round: { [Op.ne]: "group" } }
});
const roundSize = qualifiers.length;
const rn = getRoundName(roundSize);
for (let i = 0; i < roundSize / 2; i++) {
await TournamentMatch.create({
tournamentId,
round: rn,
player1Id: qualifiers[i].id,
player2Id: qualifiers[roundSize - 1 - i].id
});
}
}
async manualAssignGroups(
userToken,
clubId,
tournamentId,
assignments,
numberOfGroups,
maxGroupSize
) {
await checkAccess(userToken, clubId);
// 1) Turnier und Teilnehmerzahl validieren
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
const totalMembers = assignments.length;
if (totalMembers === 0) {
throw new Error('Keine Teilnehmer zum Verteilen');
}
// 2) Bestimme, wie viele Gruppen wir anlegen
let groupCount;
if (numberOfGroups != null) {
groupCount = Number(numberOfGroups);
if (isNaN(groupCount) || groupCount < 1) {
throw new Error('Ungültige Anzahl Gruppen');
}
} else if (maxGroupSize != null) {
const sz = Number(maxGroupSize);
if (isNaN(sz) || sz < 1) {
throw new Error('Ungültige maximale Gruppengröße');
}
groupCount = Math.ceil(totalMembers / sz);
} else {
// Fallback auf im Turnier gespeicherte Anzahl
groupCount = tournament.numberOfGroups;
if (!groupCount || groupCount < 1) {
throw new Error('Anzahl Gruppen nicht definiert');
}
}
// 3) Alte Gruppen löschen und neue anlegen
await TournamentGroup.destroy({ where: { tournamentId } });
const createdGroups = [];
for (let i = 0; i < groupCount; i++) {
const grp = await TournamentGroup.create({ tournamentId });
createdGroups.push(grp);
}
// 4) Mapping von UINummer (1…groupCount) auf reale DBID
const groupMap = {};
createdGroups.forEach((grp, idx) => {
groupMap[idx + 1] = grp.id;
});
// 5) Teilnehmer updaten
await Promise.all(
assignments.map(({ participantId, groupNumber }) => {
const dbGroupId = groupMap[groupNumber];
if (!dbGroupId) {
throw new Error(`Ungültige GruppenNummer: ${groupNumber}`);
}
return TournamentMember.update(
{ groupId: dbGroupId },
{ where: { id: participantId } }
);
})
);
// 6) Ergebnis zurückliefern wie getGroupsWithParticipants
const groups = await TournamentGroup.findAll({
where: { tournamentId },
include: [{
model: TournamentMember,
as: 'tournamentGroupMembers',
include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }]
}],
order: [['id', 'ASC']]
});
return groups.map(g => ({
groupId: g.id,
participants: g.tournamentGroupMembers.map(m => ({
id: m.id,
name: `${m.member.firstName} ${m.member.lastName}`
}))
}));
}
// services/tournamentService.js
async resetGroups(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
// löscht alle Gruppen … (inkl. CASCADE oder manuell TournamentMatch.destroy)
await TournamentMatch.destroy({ where: { tournamentId } });
await TournamentGroup.destroy({ where: { tournamentId } });
}
async resetMatches(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
await TournamentMatch.destroy({ where: { tournamentId } });
}
async removeParticipant(userToken, clubId, tournamentId, participantId) {
await checkAccess(userToken, clubId);
await TournamentMember.destroy({
where: { id: participantId, tournamentId }
});
}
// services/tournamentService.js
async deleteMatchResult(userToken, clubId, tournamentId, matchId, setToDelete) {
await checkAccess(userToken, clubId);
// Match existiert?
const match = await TournamentMatch.findOne({ where: { id: matchId, tournamentId } });
if (!match) throw new Error('Match nicht gefunden');
// Satz löschen
await TournamentResult.destroy({ where: { matchId, set: setToDelete } });
// verbleibende Sätze neu durchnummerieren
const remaining = await TournamentResult.findAll({
where: { matchId },
order: [['set', 'ASC']]
});
for (let i = 0; i < remaining.length; i++) {
const r = remaining[i];
const newSet = i + 1;
if (r.set !== newSet) {
r.set = newSet;
await r.save();
}
}
}
async reopenMatch(userToken, clubId, tournamentId, matchId) {
await checkAccess(userToken, clubId);
const match = await TournamentMatch.findOne({
where: { id: matchId, tournamentId }
});
if (!match) {
throw new Error("Match nicht gefunden");
}
// Nur den AbschlussStatus zurücksetzen, nicht die Einzelsätze
match.isFinished = false;
match.result = null; // optional: entfernt die zusammengefasste ErgebnisSpalte
await match.save();
}
async resetKnockout(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
// lösche alle Matches außer Gruppenphase
await TournamentMatch.destroy({
where: {
tournamentId,
round: { [Op.ne]: "group" }
}
});
}
}
export default new TournamentService();