// clean-match-progression.js - Single Source of Truth Lookup System // Based on the *_players.md files - hardcoded bracket progressions // Pending format selection — set by showBracketConfirmation(), read by confirmBracketGeneration() let pendingFormat = 'DE'; // Pointer into the active match's preMatchSnapshot (anchored at Start Match in // toggleActive). Reassigned by showWinnerConfirmation when the dialog opens. // Used by renderCompletionAchievements / handleConfirm / handleCancel as the baseline // for the achievement diff and the restore-on-cancel rollback target. Don't null out // after use — the underlying match.preMatchSnapshot lives on the match object. let _completionSnapshot = null; // Edit match only (handleMatchEdit): the stats as they were when the dialog opened, which Cancel // restores to. Null in the ordinary Confirm Winner flow, where Cancel restores _completionSnapshot. let _completionRestore = null; // Monotonic counter so transaction IDs stay unique even when several transactions // are minted within the same millisecond (e.g. walkover chains in // processAutoAdvancements). Undo looks transactions up by ID — duplicates would // let it strip an unrelated match's transaction from history. let _txIdCounter = 0; /** * Generate a unique transaction ID. * @returns {string} */ function generateTransactionId() { return `tx_${Date.now()}_${++_txIdCounter}`; } /** * Snapshot the achievement-relevant stats for a player. * @param {object} player * @returns {{ oneEighties: number, tons: number, lollipops: number, highOuts: number[], shortLegs: number[] }} */ function snapshotPlayerStats(player) { const s = player.stats || {}; return { oneEighties: s.oneEighties || 0, tons: s.tons || 0, lollipops: s.lollipops || 0, highOuts: Array.isArray(s.highOuts) ? [...s.highOuts] : [], shortLegs: Array.isArray(s.shortLegs) ? [...s.shortLegs] : [] }; } /** * Diff two stat snapshots. Returns the delta (what was added during the session), * or null if nothing changed. * @param {{ oneEighties, tons, lollipops, highOuts, shortLegs }} before * @param {{ oneEighties, tons, lollipops, highOuts, shortLegs }} after * @returns {object|null} */ function diffPlayerStats(before, after) { const d = {}; const oneEighties = (after.oneEighties || 0) - (before.oneEighties || 0); if (oneEighties > 0) d.oneEighties = oneEighties; const tons = (after.tons || 0) - (before.tons || 0); if (tons > 0) d.tons = tons; const lollipops = (after.lollipops || 0) - (before.lollipops || 0); if (lollipops > 0) d.lollipops = lollipops; // Arrays: find values present in after that weren't in before (handles duplicates) const beforeHighOuts = [...(before.highOuts || [])]; const added = []; (after.highOuts || []).forEach(v => { const idx = beforeHighOuts.indexOf(v); idx !== -1 ? beforeHighOuts.splice(idx, 1) : added.push(v); }); if (added.length) d.highOuts = added; const beforeShortLegs = [...(before.shortLegs || [])]; const addedSL = []; (after.shortLegs || []).forEach(v => { const idx = beforeShortLegs.indexOf(v); idx !== -1 ? beforeShortLegs.splice(idx, 1) : addedSL.push(v); }); if (addedSL.length) d.shortLegs = addedSL; return Object.keys(d).length ? d : null; } /** * Restore a player's stats from a snapshot, reversing any changes made during the session. * @param {object} player * @param {{ oneEighties, tons, lollipops, highOuts, shortLegs }} snapshot */ function restorePlayerStats(player, snapshot) { player.stats.oneEighties = snapshot.oneEighties; player.stats.tons = snapshot.tons; player.stats.lollipops = snapshot.lollipops; player.stats.highOuts = [...snapshot.highOuts]; player.stats.shortLegs = [...snapshot.shortLegs]; } /** * Renders the current session achievement diff into the completion modal summary box. * Hides the box if no achievements have been entered. */ function renderCompletionAchievements() { const container = document.getElementById('completionAchievementsSummary'); if (!container || !_completionSnapshot) { if (container) container.style.display = 'none'; return; } const rows = []; Object.entries(_completionSnapshot).forEach(([pid, snap]) => { const player = players.find(p => String(p.id) === String(pid)); if (!player) return; const diff = diffPlayerStats(snap, snapshotPlayerStats(player)); if (!diff) return; const parts = []; if (diff.oneEighties) parts.push(`${diff.oneEighties}× 180`); if (diff.tons) parts.push(`${diff.tons}× ton`); if (diff.highOuts?.length) parts.push(diff.highOuts.map(v => `high out (${v})`).join(', ')); if (diff.shortLegs?.length) parts.push(`${diff.shortLegs.length}× short leg`); if (diff.lollipops) parts.push(`${diff.lollipops}× lollipop`); if (parts.length) rows.push(`
${player.name}: ${parts.join(', ')}
`); }); if (!rows.length) { container.style.display = 'none'; return; } container.innerHTML = `
${_completionRestore ? 'Achievements for this match:' : 'Achievements entered this session:'}
${rows.join('')}
`; container.style.display = ''; } /** * Returns the correct match progression table for the current tournament format. * Uses SE_MATCH_PROGRESSION for Single Elimination, DE_MATCH_PROGRESSION for Double Elimination, * and for Groups and Cups the drawn cups' SE tables with cup IDs (cupsProgressionTable(); empty * before the cup draw, and group matches are never in it). * * @returns {Object} The progression table for the current format and bracket size */ function getProgressionTable() { const format = getFormat(); // Groups and cups: only the cups progress anyone (group matches are not in the table) if (format === 'GROUPS') return cupsProgressionTable(tournament.cups); // Swiss: only the top four progress anyone (Swiss rounds are in no table; Docs/SWISS.md) if (format === 'SWISS') return typeof Swiss !== 'undefined' ? Swiss.knockoutTable() : {}; const table = format === 'SE' ? SE_MATCH_PROGRESSION : DE_MATCH_PROGRESSION; let merged = table[tournament.bracketSize]; // Qualifiers (33-48 players): the round 0 matches in front of the 32-player table, which itself // is unchanged (Docs/QUALIFIERS.md) if (tournament.qualifiers && merged && typeof Qualifiers !== 'undefined') merged = Qualifiers.withTable(merged); // Cup and Plate: the Cup's round 1 losers into the Plate, and the Plate's own table // (Docs/CUP-AND-PLATE.md); the tables themselves are unchanged if (tournament.plate && merged && typeof Plate !== 'undefined') merged = Plate.withTable(merged); return merged; } /** * Calculates the smallest power-of-2 bracket size that fits the given player count. * SE supports smaller brackets (2, 4) that DE doesn't. 33 to 48 players play a 32-player * bracket with qualifiers before it (Docs/QUALIFIERS.md). * * @param {number} playerCount - Number of paid players * @param {'DE'|'SE'} format - Tournament format * @returns {2|4|8|16|32|null} Bracket size, or null if player count is out of range */ function calculateBracketSize(playerCount, format) { if (playerCount > 48) return null; if (playerCount > 32) return 32; // with playerCount - 32 qualifiers if (format === 'SE') { if (playerCount <= 4) return 4; } if (playerCount <= 8) return 8; if (playerCount <= 16) return 16; if (playerCount <= 32) return 32; return null; } /** * SINGLE SOURCE OF TRUTH: CORRECTED Match progression lookup tables * Based on proper double elimination mirroring rules * Format: matchId -> { winner: [targetMatchId, slot], loser: [targetMatchId, slot] } */ const DE_MATCH_PROGRESSION = { 8: { // === FRONTSIDE === 'FS-1-1': { winner: ['FS-2-1', 'player1'], loser: ['BS-1-1', 'player1'] }, 'FS-1-2': { winner: ['FS-2-1', 'player2'], loser: ['BS-1-1', 'player2'] }, 'FS-1-3': { winner: ['FS-2-2', 'player1'], loser: ['BS-1-2', 'player1'] }, 'FS-1-4': { winner: ['FS-2-2', 'player2'], loser: ['BS-1-2', 'player2'] }, 'FS-2-1': { winner: ['FS-3-1', 'player1'], loser: ['BS-2-2', 'player2'] }, 'FS-2-2': { winner: ['FS-3-1', 'player2'], loser: ['BS-2-1', 'player2'] }, 'FS-3-1': { winner: ['GRAND-FINAL', 'player1'], loser: ['BS-FINAL', 'player1'] }, // === BACKSIDE === 'BS-1-1': { winner: ['BS-2-1', 'player1'] }, 'BS-1-2': { winner: ['BS-2-2', 'player1'] }, 'BS-2-1': { winner: ['BS-3-1', 'player1'] }, 'BS-2-2': { winner: ['BS-3-1', 'player2'] }, 'BS-3-1': { winner: ['BS-FINAL', 'player2'] }, 'BS-FINAL': { winner: ['GRAND-FINAL', 'player2'] }, // Grand Final: FS champion vs BS champion // Winner = 1st place, Loser = 2nd place 'GRAND-FINAL': {} // Tournament complete }, 16: { // === FRONTSIDE === 'FS-1-1': { winner: ['FS-2-1', 'player1'], loser: ['BS-1-1', 'player1'] }, 'FS-1-2': { winner: ['FS-2-1', 'player2'], loser: ['BS-1-1', 'player2'] }, 'FS-1-3': { winner: ['FS-2-2', 'player1'], loser: ['BS-1-2', 'player1'] }, 'FS-1-4': { winner: ['FS-2-2', 'player2'], loser: ['BS-1-2', 'player2'] }, 'FS-1-5': { winner: ['FS-2-3', 'player1'], loser: ['BS-1-3', 'player1'] }, 'FS-1-6': { winner: ['FS-2-3', 'player2'], loser: ['BS-1-3', 'player2'] }, 'FS-1-7': { winner: ['FS-2-4', 'player1'], loser: ['BS-1-4', 'player1'] }, 'FS-1-8': { winner: ['FS-2-4', 'player2'], loser: ['BS-1-4', 'player2'] }, 'FS-2-1': { winner: ['FS-3-1', 'player1'], loser: ['BS-2-3', 'player2'] }, 'FS-2-2': { winner: ['FS-3-1', 'player2'], loser: ['BS-2-4', 'player2'] }, 'FS-2-3': { winner: ['FS-3-2', 'player1'], loser: ['BS-2-1', 'player2'] }, 'FS-2-4': { winner: ['FS-3-2', 'player2'], loser: ['BS-2-2', 'player2'] }, // Winners side semis 'FS-3-1': { winner: ['FS-4-1', 'player1'], loser: ['BS-4-2', 'player2'] }, // mirror into BS-4 'FS-3-2': { winner: ['FS-4-1', 'player2'], loser: ['BS-4-1', 'player2'] }, // Winners side final 'FS-4-1': { winner: ['GRAND-FINAL', 'player1'], loser: ['BS-FINAL', 'player1'] }, // === BACKSIDE (16) === // BS-R1 (4 matches) winners → BS-R2.player1 'BS-1-1': { winner: ['BS-2-1', 'player1'] }, 'BS-1-2': { winner: ['BS-2-2', 'player1'] }, 'BS-1-3': { winner: ['BS-2-3', 'player1'] }, 'BS-1-4': { winner: ['BS-2-4', 'player1'] }, // BS-R2 winners → BS-R3.player1 'BS-2-1': { winner: ['BS-3-1', 'player1'] }, 'BS-2-2': { winner: ['BS-3-1', 'player2'] }, // player2 supplied later by FS-3-2 loser 'BS-2-3': { winner: ['BS-3-2', 'player1'] }, 'BS-2-4': { winner: ['BS-3-2', 'player2'] }, // player2 supplied later by FS-3-1 loser // BS-R3 winners go straight to BS-R4 player1 'BS-3-1': { winner: ['BS-4-1', 'player1'] }, 'BS-3-2': { winner: ['BS-4-2', 'player1'] }, // BS-R4 winners meet in BS-R5-1 'BS-4-1': { winner: ['BS-5-1', 'player1'] }, 'BS-4-2': { winner: ['BS-5-1', 'player2'] }, // BS-R5 winner → BS-FINAL.player2 'BS-5-1': { winner: ['BS-FINAL', 'player2'] }, // BS champion → GRAND-FINAL.player2 'BS-FINAL': { winner: ['GRAND-FINAL', 'player2'] }, // Grand Final: FS champion vs BS champion // Winner = 1st place, Loser = 2nd place 'GRAND-FINAL': {} // Tournament complete }, 32: { // === FRONTSIDE MATCHES === // Round 1 (16 matches: FS-1-1 through FS-1-16) 'FS-1-1': { winner: ['FS-2-1', 'player1'], loser: ['BS-1-1', 'player1'] }, 'FS-1-2': { winner: ['FS-2-1', 'player2'], loser: ['BS-1-1', 'player2'] }, 'FS-1-3': { winner: ['FS-2-2', 'player1'], loser: ['BS-1-2', 'player1'] }, 'FS-1-4': { winner: ['FS-2-2', 'player2'], loser: ['BS-1-2', 'player2'] }, 'FS-1-5': { winner: ['FS-2-3', 'player1'], loser: ['BS-1-3', 'player1'] }, 'FS-1-6': { winner: ['FS-2-3', 'player2'], loser: ['BS-1-3', 'player2'] }, 'FS-1-7': { winner: ['FS-2-4', 'player1'], loser: ['BS-1-4', 'player1'] }, 'FS-1-8': { winner: ['FS-2-4', 'player2'], loser: ['BS-1-4', 'player2'] }, 'FS-1-9': { winner: ['FS-2-5', 'player1'], loser: ['BS-1-5', 'player1'] }, 'FS-1-10': { winner: ['FS-2-5', 'player2'], loser: ['BS-1-5', 'player2'] }, 'FS-1-11': { winner: ['FS-2-6', 'player1'], loser: ['BS-1-6', 'player1'] }, 'FS-1-12': { winner: ['FS-2-6', 'player2'], loser: ['BS-1-6', 'player2'] }, 'FS-1-13': { winner: ['FS-2-7', 'player1'], loser: ['BS-1-7', 'player1'] }, 'FS-1-14': { winner: ['FS-2-7', 'player2'], loser: ['BS-1-7', 'player2'] }, 'FS-1-15': { winner: ['FS-2-8', 'player1'], loser: ['BS-1-8', 'player1'] }, 'FS-1-16': { winner: ['FS-2-8', 'player2'], loser: ['BS-1-8', 'player2'] }, // Round 2 (8 matches: FS-2-1 through FS-2-8) 'FS-2-1': { winner: ['FS-3-1', 'player1'], loser: ['BS-2-5', 'player2'] }, 'FS-2-2': { winner: ['FS-3-1', 'player2'], loser: ['BS-2-6', 'player2'] }, 'FS-2-3': { winner: ['FS-3-2', 'player1'], loser: ['BS-2-7', 'player2'] }, 'FS-2-4': { winner: ['FS-3-2', 'player2'], loser: ['BS-2-8', 'player2'] }, 'FS-2-5': { winner: ['FS-3-3', 'player1'], loser: ['BS-2-1', 'player2'] }, 'FS-2-6': { winner: ['FS-3-3', 'player2'], loser: ['BS-2-2', 'player2'] }, 'FS-2-7': { winner: ['FS-3-4', 'player1'], loser: ['BS-2-3', 'player2'] }, 'FS-2-8': { winner: ['FS-3-4', 'player2'], loser: ['BS-2-4', 'player2'] }, // Round 3 (4 matches: FS-3-1 through FS-3-4) 'FS-3-1': { winner: ['FS-4-1', 'player1'], loser: ['BS-4-2', 'player2'] }, 'FS-3-2': { winner: ['FS-4-1', 'player2'], loser: ['BS-4-1', 'player2'] }, 'FS-3-3': { winner: ['FS-4-2', 'player1'], loser: ['BS-4-4', 'player2'] }, 'FS-3-4': { winner: ['FS-4-2', 'player2'], loser: ['BS-4-3', 'player2'] }, // Round 4 (2 matches: FS-4-1 through FS-4-2) 'FS-4-1': { winner: ['FS-5-1', 'player1'], loser: ['BS-6-2', 'player2'] }, 'FS-4-2': { winner: ['FS-5-1', 'player2'], loser: ['BS-6-1', 'player2'] }, // Round 5 (1 match: FS-5-1) - Frontside Final 'FS-5-1': { winner: ['GRAND-FINAL', 'player1'], loser: ['BS-FINAL', 'player1'] }, // === BACKSIDE MATCHES === // Round 1 (8 matches: BS-1-1 through BS-1-8) // FS-R1 losers compete, losers eliminated // Losers gets 25th-32nd place 'BS-1-1': { winner: ['BS-2-1', 'player1'] }, 'BS-1-2': { winner: ['BS-2-2', 'player1'] }, 'BS-1-3': { winner: ['BS-2-3', 'player1'] }, 'BS-1-4': { winner: ['BS-2-4', 'player1'] }, 'BS-1-5': { winner: ['BS-2-5', 'player1'] }, 'BS-1-6': { winner: ['BS-2-6', 'player1'] }, 'BS-1-7': { winner: ['BS-2-7', 'player1'] }, 'BS-1-8': { winner: ['BS-2-8', 'player1'] }, // Round 2 (8 matches: BS-2-1 through BS-2-8) // BS-R1 winners meet FS-R2 losers, losers eliminated // Losers gets 17th-24th place 'BS-2-1': { winner: ['BS-3-1', 'player1'] }, 'BS-2-2': { winner: ['BS-3-1', 'player2'] }, 'BS-2-3': { winner: ['BS-3-2', 'player1'] }, 'BS-2-4': { winner: ['BS-3-2', 'player2'] }, 'BS-2-5': { winner: ['BS-3-3', 'player1'] }, 'BS-2-6': { winner: ['BS-3-3', 'player2'] }, 'BS-2-7': { winner: ['BS-3-4', 'player1'] }, 'BS-2-8': { winner: ['BS-3-4', 'player2'] }, // Round 3 (4 matches: BS-3-1 through BS-3-4) // Only backside winners, no frontside input, losers eliminated // Losers gets 13th-16tth place 'BS-3-1': { winner: ['BS-4-1', 'player1'] }, 'BS-3-2': { winner: ['BS-4-2', 'player1'] }, 'BS-3-3': { winner: ['BS-4-3', 'player1'] }, 'BS-3-4': { winner: ['BS-4-4', 'player1'] }, // Round 4 (4 matches: BS-4-1 through BS-4-4) // BS-R3 winners meet FS-R3 losers, losers eliminated // Losers gets 9th-12th place 'BS-4-1': { winner: ['BS-5-1', 'player1'] }, 'BS-4-2': { winner: ['BS-5-1', 'player2'] }, 'BS-4-3': { winner: ['BS-5-2', 'player1'] }, 'BS-4-4': { winner: ['BS-5-2', 'player2'] }, // Round 5 (2 matches: BS-5-1 through BS-5-2) // Only backside winners, no frontside input, losers eliminated // Losers gets 7th-8th place 'BS-5-1': { winner: ['BS-6-1', 'player1'] }, 'BS-5-2': { winner: ['BS-6-2', 'player1'] }, // Round 6 (2 matches: BS-6-1 through BS-6-2) // BS-R5 winners meet FS-R4 losers, losers eliminated // Losers gets 5th-6th place 'BS-6-1': { winner: ['BS-7-1', 'player1'] }, 'BS-6-2': { winner: ['BS-7-1', 'player2'] }, // Round 7 (1 matches: BS-7-1) - Backside final // Only backside winners, no frontside input, losers eliminated // Loser gets 4th place 'BS-7-1': { winner: ['BS-FINAL', 'player2'] }, // === FINAL MATCHES === // Backside Final: FS-5-1 loser vs BS-7-1 winner // Loser gets 3rd place 'BS-FINAL': { winner: ['GRAND-FINAL', 'player2'] }, // Grand Final: FS champion vs BS champion // Winner = 1st place, Loser = 2nd place 'GRAND-FINAL': {} // Tournament complete } // TODO: Add 48-player progression when needed }; /** * SINGLE ELIMINATION: Match progression lookup tables * Derived from DE_MATCH_PROGRESSION frontside entries with loser paths removed. * Format: matchId -> { winner: [targetMatchId, slot] } * The final match in each bracket has {} — tournament complete. * * These tables are separate from DE to eliminate any risk of interference. * Verify by inspection: each SE table should match the FS- entries of the * corresponding DE table, with loser entries stripped and the last round * match having {} instead of advancing to GRAND-FINAL. */ const SE_MATCH_PROGRESSION = { 4: { // Round 1 — Semifinals (2 matches) 'FS-1-1': { winner: ['FS-3-1', 'player1'], loser: ['FS-2-1', 'player1'] }, 'FS-1-2': { winner: ['FS-3-1', 'player2'], loser: ['FS-2-1', 'player2'] }, // Round 2 — Bronze (semifinal losers) 'FS-2-1': {}, // No further progression // Round 3 — Final (semifinal winners) 'FS-3-1': {} // Tournament complete }, 8: { // Round 1 — Quarterfinals (4 matches) 'FS-1-1': { winner: ['FS-2-1', 'player1'] }, 'FS-1-2': { winner: ['FS-2-1', 'player2'] }, 'FS-1-3': { winner: ['FS-2-2', 'player1'] }, 'FS-1-4': { winner: ['FS-2-2', 'player2'] }, // Round 2 — Semifinals (2 matches) 'FS-2-1': { winner: ['FS-4-1', 'player1'], loser: ['FS-3-1', 'player1'] }, 'FS-2-2': { winner: ['FS-4-1', 'player2'], loser: ['FS-3-1', 'player2'] }, // Round 3 — Bronze (semifinal losers) 'FS-3-1': {}, // No further progression // Round 4 — Final (semifinal winners) 'FS-4-1': {} // Tournament complete }, 16: { // Round 1 (8 matches) 'FS-1-1': { winner: ['FS-2-1', 'player1'] }, 'FS-1-2': { winner: ['FS-2-1', 'player2'] }, 'FS-1-3': { winner: ['FS-2-2', 'player1'] }, 'FS-1-4': { winner: ['FS-2-2', 'player2'] }, 'FS-1-5': { winner: ['FS-2-3', 'player1'] }, 'FS-1-6': { winner: ['FS-2-3', 'player2'] }, 'FS-1-7': { winner: ['FS-2-4', 'player1'] }, 'FS-1-8': { winner: ['FS-2-4', 'player2'] }, // Round 2 — Quarterfinals (4 matches) 'FS-2-1': { winner: ['FS-3-1', 'player1'] }, 'FS-2-2': { winner: ['FS-3-1', 'player2'] }, 'FS-2-3': { winner: ['FS-3-2', 'player1'] }, 'FS-2-4': { winner: ['FS-3-2', 'player2'] }, // Round 3 — Semifinals (2 matches) 'FS-3-1': { winner: ['FS-5-1', 'player1'], loser: ['FS-4-1', 'player1'] }, 'FS-3-2': { winner: ['FS-5-1', 'player2'], loser: ['FS-4-1', 'player2'] }, // Round 4 — Bronze (semifinal losers) 'FS-4-1': {}, // No further progression // Round 5 — Final (semifinal winners) 'FS-5-1': {} // Tournament complete }, 32: { // Round 1 (16 matches) 'FS-1-1': { winner: ['FS-2-1', 'player1'] }, 'FS-1-2': { winner: ['FS-2-1', 'player2'] }, 'FS-1-3': { winner: ['FS-2-2', 'player1'] }, 'FS-1-4': { winner: ['FS-2-2', 'player2'] }, 'FS-1-5': { winner: ['FS-2-3', 'player1'] }, 'FS-1-6': { winner: ['FS-2-3', 'player2'] }, 'FS-1-7': { winner: ['FS-2-4', 'player1'] }, 'FS-1-8': { winner: ['FS-2-4', 'player2'] }, 'FS-1-9': { winner: ['FS-2-5', 'player1'] }, 'FS-1-10': { winner: ['FS-2-5', 'player2'] }, 'FS-1-11': { winner: ['FS-2-6', 'player1'] }, 'FS-1-12': { winner: ['FS-2-6', 'player2'] }, 'FS-1-13': { winner: ['FS-2-7', 'player1'] }, 'FS-1-14': { winner: ['FS-2-7', 'player2'] }, 'FS-1-15': { winner: ['FS-2-8', 'player1'] }, 'FS-1-16': { winner: ['FS-2-8', 'player2'] }, // Round 2 (8 matches) 'FS-2-1': { winner: ['FS-3-1', 'player1'] }, 'FS-2-2': { winner: ['FS-3-1', 'player2'] }, 'FS-2-3': { winner: ['FS-3-2', 'player1'] }, 'FS-2-4': { winner: ['FS-3-2', 'player2'] }, 'FS-2-5': { winner: ['FS-3-3', 'player1'] }, 'FS-2-6': { winner: ['FS-3-3', 'player2'] }, 'FS-2-7': { winner: ['FS-3-4', 'player1'] }, 'FS-2-8': { winner: ['FS-3-4', 'player2'] }, // Round 3 — Quarterfinals (4 matches) 'FS-3-1': { winner: ['FS-4-1', 'player1'] }, 'FS-3-2': { winner: ['FS-4-1', 'player2'] }, 'FS-3-3': { winner: ['FS-4-2', 'player1'] }, 'FS-3-4': { winner: ['FS-4-2', 'player2'] }, // Round 4 — Semifinals (2 matches) 'FS-4-1': { winner: ['FS-6-1', 'player1'], loser: ['FS-5-1', 'player1'] }, 'FS-4-2': { winner: ['FS-6-1', 'player2'], loser: ['FS-5-1', 'player2'] }, // Round 5 — Bronze (semifinal losers) 'FS-5-1': {}, // No further progression // Round 6 — Final (semifinal winners) 'FS-6-1': {} // Tournament complete } }; /** * GROUPS AND CUPS: the fixed order of each group's matches, by group size (Docs/GROUPS-AND-CUPS.md). * Each entry is [player 1, player 2, referee] by seed in the group (1 = the first player drawn into * it); the referee is from the group, or null. Group matches progress no one (everybody plays * everybody), so they are not in any progression table. * * Group of 4: rounds are matches 1-2, 3-4 and 5-6; duties 2/2/1/1, nobody referees twice in a row, * and only two back-to-back plays (players 2 and 3, once each), the minimum for four players. * Group of 3: the player not playing referees, once each. */ const GROUP_SCHEDULES = { 2: [[1, 2, null]], 3: [[2, 3, 1], [1, 3, 2], [1, 2, 3]], 4: [[1, 4, 2], [2, 3, 4], [2, 4, 1], [1, 3, 2], [3, 4, 1], [1, 2, 3]] }; /** * The fixed order of a round robin of n players: GROUP_SCHEDULES for up to four; for five to eight * (one group, or groups of five or six under Largest group), the circle method's rounds in order, each match with a * referee from the group: never twice in a row, then the fewest duties so far (a player who plays * in the same round counts one extra, so the one sitting the round out is asked first), then the * lowest seed. Every pair meets once; duties come out even or within one (two at seven players). * The same n always gives the same schedule. Entries are [player 1, player 2, referee] by seed (1 = first drawn). * @param {number} n - players in the group * @returns {Array<[number, number, number|null]>} */ function roundRobinSchedule(n) { if (GROUP_SCHEDULES[n]) return GROUP_SCHEDULES[n]; const m = n % 2 ? n + 1 : n; // an odd group gets a "sits out" slot (0) const ring = Array.from({ length: m }, (_, i) => (i < n ? i + 1 : 0)); const out = [], duties = {}; let lastRef = null; for (let r = 0; r < m - 1; r++) { const pairs = []; for (let i = 0; i < m / 2; i++) pairs.push([ring[i], ring[m - 1 - i]]); const real = pairs.filter(p => p[0] && p[1]); const playing = new Set(real.flat()); // not the one sitting the round out real.forEach(([a, b]) => { const candidates = []; for (let p = 1; p <= n; p++) if (p !== a && p !== b) candidates.push(p); const busy = p => playing.has(p) ? 1 : 0; const score = p => [p === lastRef ? 1 : 0, (duties[p] || 0) + busy(p), busy(p), p]; candidates.sort((x, y) => { const sx = score(x), sy = score(y); for (let k = 0; k < sx.length; k++) if (sx[k] !== sy[k]) return sx[k] - sy[k]; return 0; }); const ref = candidates[0]; duties[ref] = (duties[ref] || 0) + 1; lastRef = ref; out.push(a < b ? [a, b, ref] : [b, a, ref]); }); ring.splice(1, 0, ring.pop()); // rotate everyone but the first } return out; } /** * A cup match's ID: the cup letter and the round's short name, from its single-elimination ID. * 8 players: FS-1-2 → A-QF2, FS-2-1 → A-SF1, FS-3-1 → A-B (bronze final), FS-4-1 → A-F (final); * 16 players' first round: FS-1-3 → A-R1-3. A cup is SE_MATCH_PROGRESSION[size] with its IDs renamed * this way, so the SE table stays the one source of cup progression. * @param {'A'|'B'} cup * @param {string} seId - e.g. 'FS-2-1' * @param {4|8|16} size - the cup's bracket size * @returns {string} */ function cupMatchId(cup, seId, size) { const [, r, n] = seId.split('-').map(Number); const total = { 4: 3, 8: 4, 16: 5, 32: 6 }[size]; if (r === total) return `${cup}-F`; if (r === total - 1) return `${cup}-B`; if (r === total - 2) return `${cup}-SF${n}`; if (r === total - 3) return `${cup}-QF${n}`; return `${cup}-R${r}-${n}`; } /** * The cups' progression: each drawn cup's SE_MATCH_PROGRESSION table with its IDs renamed * (cupMatchId), the two joined. Empty before the cups are drawn. Built from tournament.cups only. * @param {{A?: {size: number}, B?: {size: number}|null}|null|undefined} cups - tournament.cups * @returns {Object} matchId -> { winner: [matchId, slot], loser: [matchId, slot] } */ function cupsProgressionTable(cups) { const table = {}; if (!cups) return table; ['A', 'B'].forEach(cup => { const c = cups[cup]; const se = c && SE_MATCH_PROGRESSION[c.size]; if (!se) return; Object.entries(se).forEach(([seId, rule]) => { const out = {}; ['winner', 'loser'].forEach(kind => { if (rule[kind]) out[kind] = [cupMatchId(cup, rule[kind][0], c.size), rule[kind][1]]; }); table[cupMatchId(cup, seId, c.size)] = out; }); }); return table; } /** * Advances winner and loser to their next matches using DE_MATCH_PROGRESSION lookup table. * This is the ONLY function that moves players between matches - single source of truth. * * @param {string} matchId - The match ID (e.g., 'FS-1-1', 'BS-2-3') * @param {Player} winner - The winning player object * @param {Player} loser - The losing player object * @returns {boolean} True if advancement succeeded, false if tournament/progression missing * * @example * // After completing match FS-1-1 * advancePlayer('FS-1-1', winnerPlayer, loserPlayer); * // Winner goes to FS-2-1 player1 slot * // Loser goes to BS-1-1 player1 slot */ function advancePlayer(matchId, winner, loser) { if (!tournament || !tournament.bracketSize) { console.error('No tournament or bracket size available'); return false; } let success = true; const progression = getProgressionTable(); if (!progression) { console.error(`No progression rules for ${tournament.bracketSize}-player bracket`); return false; } const rule = progression[matchId]; if (!rule) { // No further progression (e.g., GRAND-FINAL)  just stop silently return true; } // Place winner if (rule.winner) { const [targetMatchId, slot] = rule.winner; const targetMatch = matches.find(m => m.id === targetMatchId); if (targetMatch) { targetMatch[slot] = { id: winner.id, name: winner.name, paid: winner.paid, stats: winner.stats }; } else { console.error(`Target match ${targetMatchId} not found for winner`); success = false; } } // Place loser (if rule exists) if (rule.loser && loser) { const [targetMatchId, slot] = rule.loser; const targetMatch = matches.find(m => m.id === targetMatchId); if (targetMatch) { targetMatch[slot] = { id: loser.id, name: loser.name, paid: loser.paid, stats: loser.stats }; } else { console.error(`Target match ${targetMatchId} not found for loser`); success = false; } } return success; } /** * Completes a match by setting winner/loser and advancing players using lookup table. * Records transaction to history before making changes for undo support. * * @param {string} matchId - The match ID to complete (e.g., 'FS-1-1') * @param {number} winnerPlayerNumber - Which player won: 1 for player1, 2 for player2 * @param {number} [winnerLegs=0] - Number of legs won by winner (for score display) * @param {number} [loserLegs=0] - Number of legs won by loser (for score display) * @param {CompletionType} [completionType='MANUAL'] - 'MANUAL' for user action, 'AUTO' for walkover * @param {object|null} [achievements=null] - Achievement delta recorded during match completion session * @returns {boolean} True if match completed successfully, false on error * * @example * // Complete match with player 1 winning 3-1 * completeMatch('FS-1-1', 1, 3, 1, 'MANUAL'); * * @example * // Auto-advance walkover match * completeMatch('FS-1-2', 2, 0, 0, 'AUTO'); */ function completeMatch(matchId, winnerPlayerNumber, winnerLegs = 0, loserLegs = 0, completionType = 'MANUAL', achievements = null, rawLegs = null, firstStarter = null) { const match = matches.find(m => m.id === matchId); if (!match) { console.error('Match ' + matchId + ' not found'); return false; } const winner = winnerPlayerNumber === 1 ? match.player1 : match.player2; const loser = winnerPlayerNumber === 1 ? match.player2 : match.player1; if (!winner || !loser) { console.error('Invalid player selection'); return false; } // --- START TRANSACTION LOGIC --- // Skip transaction creation during rebuild to prevent corruption if (!window.rebuildInProgress) { const transaction = { id: generateTransactionId(), type: 'COMPLETE_MATCH', completionType: completionType, description: `${matchId}: ${winner.name} (ID: ${winner.id}) defeats ${loser.name} (ID: ${loser.id})`, timestamp: new Date().toISOString(), matchId: matchId, winner: winner, loser: loser, // beforeState removed - never used by undo system (saves ~98% storage per transaction) achievements: achievements || { [winner.id]: null, [loser.id]: null } }; saveTransaction(transaction); } // --- END TRANSACTION LOGIC --- // Apply changes to the current state match.winner = winner; match.loser = loser; match.completed = true; match.active = false; match.completedAt = Date.now(); // Add completion timestamp if (winnerLegs > 0 || loserLegs > 0) { match.finalScore = { winnerLegs, loserLegs, winnerId: winner.id, loserId: loser.id }; } const success = advancePlayer(matchId, winner, loser); if (success) { // Persisting is deferred to a single saveTournament() at the end of this path // (6.2) — the hooks below still mutate `tournament` (placements, status, // readOnly), and every reader in between works off the in-memory object. if (typeof updateMatchHistory === 'function') updateMatchHistory(); // Write to match register (fire-and-forget; skip AUTO walkovers, and qualifiers: nothing in a // qualifier counts, Docs/QUALIFIERS.md) if (completionType !== 'AUTO' && match.side !== 'qualifier' && !window.rebuildInProgress && typeof NewtonDB !== 'undefined') { const _p1 = match.player1, _p2 = match.player2; const _dbMatch = { tournamentId: String(tournament.id), tournamentName: tournament.name, tournamentFormat: tournament.format || 'DE', matchId: matchId, matchType: completionType === 'MANUAL' ? 'MANUAL' : 'CHALKER', completedAt: Math.floor(Date.now() / 1000), player1Id: String(_p1.id), player1Name: _p1.name, player2Id: String(_p2.id), player2Name: _p2.name, winner: winnerPlayerNumber, firstStarter: firstStarter || null, legsWon: { p1: winnerPlayerNumber === 1 ? winnerLegs : loserLegs, p2: winnerPlayerNumber === 2 ? winnerLegs : loserLegs }, legs: rawLegs || null, achievements: NewtonDB.slotAchievements(achievements, _p1.id, _p2.id), // { p1, p2 }; the transaction keeps the per-id map format: { sc: (typeof config !== 'undefined' && config.legs && config.legs.x01Format) || 501, bo: match.legs || 3 } }; const _matchSaved = NewtonDB.saveMatch(_dbMatch).catch(e => console.warn('NewtonDB saveMatch failed:', e)); const _metaSaved = NewtonDB.saveTournamentMeta({ tournamentId: String(tournament.id), tournamentName: tournament.name, tournamentFormat: tournament.format || 'DE', playerCount: (typeof players !== 'undefined' ? players.filter(p => !p.isBye).length : 0), startedAt: Math.floor(Date.now() / 1000) }).catch(e => console.warn('NewtonDB saveTournamentMeta failed:', e)); // Analytics caches tournament records and their match lists; drop the cached // copies once these writes land, so re-completing a match in an unlocked // tournament can't leave Analytics showing pre-edit data. Deliberately after // the writes, not before: invalidating first would let a read in between // repopulate the cache with the old records. Guarded — Analytics may not be // loaded, and this must never affect match completion. Promise.all([_matchSaved, _metaSaved]).then(() => { if (typeof NewtonHistory !== 'undefined' && NewtonHistory.invalidateCache) { NewtonHistory.invalidateCache(); } }); } // Swiss: the last result of a round draws the next round, or after the last round the top // four (Docs/SWISS.md); the completion check below then sees the new matches if (match.side === 'swiss' && !window.rebuildInProgress) swissAfterResult(match); // Calculate live rankings after every match completion (reuse existing logic) // Only calculate rankings during normal play, not during rebuild or auto-advancement processing if (!window.rebuildInProgress && !window.processingAutoAdvancements) { try { calculateAllRankings(); } catch (e) { console.warn('Could not calculate live rankings:', e); } } // BS-FINAL completion hook: set 3rd place immediately for consistent UX try { if (matchId === 'BS-FINAL') { console.log('🥉 Backside Final completed - setting 3rd place...'); // Set 3rd place for the loser if (!tournament.placements) { tournament.placements = {}; } tournament.placements[String(loser.id)] = 3; console.log(`✓ 3rd place: ${loser.name}`); // Note: This placement will be cleared and recalculated when Grand Final completes // This ensures consistency with other backside matches that set placement immediately } } catch (e) { console.error('BS-FINAL completion error', { matchId, winner, loser, error: e }); } // SE Bronze completion hook: set 3rd and 4th place immediately try { const format = getFormat(); if (format === 'SE' && isSEBronzeMatch(matchId, tournament.bracketSize)) { console.log('🥉 Bronze match completed - setting 3rd/4th place...'); if (!tournament.placements) { tournament.placements = {}; } tournament.placements[String(winner.id)] = 3; tournament.placements[String(loser.id)] = 4; console.log(`✓ 3rd place: ${winner.name}, 4th place: ${loser.name}`); } } catch (e) { console.error('Bronze completion error', { matchId, winner, loser, error: e }); } // Tournament completion hook: detect final match and set placements // DE: GRAND-FINAL has {} progression. SE: last FS round has {} progression. // SE bronze also has {} but should NOT trigger completion — only the SE final does. try { // Computed inside the try so a throw here can't skip the save below const completionTable = getProgressionTable(); const completionRule = completionTable && completionTable[matchId]; const isSEBronze = getFormat() === 'SE' && isSEBronzeMatch(matchId, tournament.bracketSize); // Groups and cups: both cups' bronze and final have {} too, so the format says when it // is over instead: when every drawn cup's final has been played // Cup and Plate: over when every match is played (the Cup's final and bronze final and the // Plate's), whichever comes last const isTournamentFinal = getFormat() === 'GROUPS' ? (typeof Groups !== 'undefined' && Groups.isComplete()) : getFormat() === 'SWISS' ? (typeof Swiss !== 'undefined' && Swiss.isComplete()) : tournament.plate ? matches.every(m => m.completed) : completionRule && Object.keys(completionRule).length === 0 && !isSEBronze; if (isTournamentFinal) { const format = getFormat(); console.log(`🏆 ${format} Final completed - calculating all rankings...`); // Clear any existing placements tournament.placements = {}; // 1st and 2nd place (final match winner/loser); groups and cups places everyone // from both cups in calculateAllRankings() below if (format !== 'GROUPS' && format !== 'SWISS') { // with a Plate the last match played may be the Plate's: 1st and 2nd are the Cup final's const cupFinal = tournament.plate ? matches.find(m => isSEFinalMatch(m.id, tournament.bracketSize)) : null; const top = cupFinal && cupFinal.completed ? cupFinal : { winner, loser }; tournament.placements[String(top.winner.id)] = 1; tournament.placements[String(top.loser.id)] = 2; } if (format === 'DE') { // 3rd place (BS-FINAL loser) const bsFinal = matches.find(m => m.id === 'BS-FINAL'); if (bsFinal && bsFinal.completed && bsFinal.loser && bsFinal.loser.id) { tournament.placements[String(bsFinal.loser.id)] = 3; } } // Calculate remaining rankings based on bracket size and format calculateAllRankings(); tournament.status = 'completed'; tournament.readOnly = true; console.log(`✓ Tournament completed with full rankings — Final: ${winner.name} defeats ${loser.name}`); // Finalize tournament in match register (fire-and-forget) if (typeof NewtonDB !== 'undefined') { const _configSnapshot = (typeof config !== 'undefined') ? JSON.parse(JSON.stringify(config)) : {}; const _tournamentAchievements = {}; if (typeof players !== 'undefined') { players.forEach(p => { if (p.stats) _tournamentAchievements[String(p.id)] = { name: p.name, stats: Object.assign({}, p.stats) }; }); } NewtonDB.finalizeTournament( String(tournament.id), _configSnapshot, _tournamentAchievements, Math.floor(Date.now() / 1000) ).then(() => { // Store placements for Analytics ranking points if (tournament.placements) { return NewtonDB.getTournament(String(tournament.id)).then(t => { if (t) { t.placements = tournament.placements; // qualifiers: who lost one, for Taking part (Docs/QUALIFIERS.md) if (tournament.notQualified) t.notQualified = tournament.notQualified; else delete t.notQualified; // Cup and Plate: Analytics names the format (Docs/CUP-AND-PLATE.md) if (tournament.plate) t.plate = tournament.plate; else delete t.plate; return NewtonDB.saveTournamentMeta(t); } }); } }).then(() => { // Reconcile: attribute any achievements entered outside // the match completion dialog to each player's last match return NewtonDB.reconcileMatchAchievements( String(tournament.id), _tournamentAchievements ); }).then(() => { // Invalidate Analytics cache so the new tournament appears without reload if (typeof NewtonHistory !== 'undefined' && NewtonHistory.invalidateCache) { NewtonHistory.invalidateCache(); } }).catch(e => console.warn('NewtonDB finalizeTournament failed:', e)); } if (typeof updateMatchHistory === 'function') updateMatchHistory(); // Auto-upload tournament to server (fire-and-forget, respects config) if (typeof autoUploadTournament === 'function' && typeof config !== 'undefined' && config.server && config.server.autoUpload) { autoUploadTournament(); } // Proactively refresh results UI after completion if (typeof displayResults === 'function') { try { displayResults(); // HELP SYSTEM INTEGRATION - Tournament completed if (typeof showHelpHint === 'function') { const first = Object.keys(tournament.placements || {}).find(id => tournament.placements[id] === 1); const champion = format === 'SWISS' ? ((players.find(p => String(p.id) === first)) || winner) : format === 'GROUPS' ? (matches.find(m => m.id === 'A-F') || {}).winner || winner : tournament.plate ? (matches.find(m => isSEFinalMatch(m.id, tournament.bracketSize)) || {}).winner || winner : winner; showHelpHint(`🏆 Tournament completed! ${champion.name} wins. Check results in Match Controls or on Registration page.`, 8000); } } catch (e) { console.warn('displayResults failed after completion', e); } } } } catch (e) { console.error('Tournament completion error', { matchId, winner, loser, error: e }); } // Single persist for the whole completion (6.2). Everything above mutates the // in-memory tournament — the match result, live rankings, and any placement or // tournament-completion hook — and this writes all of it once. Before // processAutoAdvancements(), which completes walkovers through this same path // and so persists its own results. saveTournament(); if (typeof updateResultsTable === 'function') updateResultsTable(); // Skip auto-advancements during rebuild to prevent transaction corruption if (!window.rebuildInProgress) { processAutoAdvancements(); } return true; } else { console.error(`Failed to advance players from ${matchId}`); // Here we should ideally roll back the transaction, but for now we'll log an error return false; } } /** * Calculates final tournament placements for all players based on match outcomes. * Called when Grand Final is completed. Determines 4th place and beyond using * which backside match each player lost. * * @returns {void} * * @description * - 1st-3rd: Set by Grand Final and BS-FINAL match outcomes * - 4th+: Determined by which backside match the player lost * - Updates tournament.placements with player ID to rank mapping */ /** * Calculates SE rankings based on which round each player was eliminated in. * Generic formula: losers in round R of a bracket with totalRounds rounds * get placement 2^(totalRounds - R) + 1. * * Examples (8-player, 3 rounds): R1 losers → 5th, R2 losers → 3rd, R3 loser → 2nd * Examples (4-player, 2 rounds): R1 losers → 3rd, R2 loser → 2nd */ function calculateSERankings() { const bracketSize = tournament.bracketSize; const totalRounds = Math.ceil(Math.log2(bracketSize)); console.log(`Calculating SE rankings for ${bracketSize}-player bracket (${totalRounds} natural rounds)...`); // Scan completed FS matches (excluding bronze and final rounds — those are handled by completion hooks) const completedMatches = matches.filter(m => m.completed && m.loser && m.loser.id && m.id.startsWith('FS-') && !isSEBronzeMatch(m.id, bracketSize) && !isSEFinalMatch(m.id, bracketSize) ); for (const match of completedMatches) { const loserId = String(match.loser.id); // Skip if already placed (e.g., by bronze completion hook) if (tournament.placements[loserId]) continue; // Generic formula: losers in round R get placement 2^(totalRounds - R) + 1 const round = match.round; const placement = Math.pow(2, totalRounds - round) + 1; tournament.placements[loserId] = placement; console.log(`SE placement: ${match.loser.name} eliminated in R${round} → ${placement}${placement === 3 ? 'rd' : 'th'}`); } // Bronze match overrides: if played, winner = 3rd, loser = 4th const bronzeMatch = matches.find(m => isSEBronzeMatch(m.id, bracketSize) && m.completed); if (bronzeMatch && bronzeMatch.winner && bronzeMatch.loser) { tournament.placements[String(bronzeMatch.winner.id)] = 3; tournament.placements[String(bronzeMatch.loser.id)] = 4; console.log(`SE bronze: ${bronzeMatch.winner.name} → 3rd, ${bronzeMatch.loser.name} → 4th`); } } function calculateAllRankings() { if (!tournament || !tournament.bracketSize) { console.error('Cannot calculate rankings: missing tournament or bracket size'); return; } const bracketSize = tournament.bracketSize; console.log(`Calculating rankings for ${bracketSize}-player bracket...`); const format = getFormat(); // Qualifiers: their losers are not qualified, shared 33rd (Docs/QUALIFIERS.md) if (format !== 'GROUPS' && typeof Qualifiers !== 'undefined') { if (!tournament.placements) tournament.placements = {}; Qualifiers.place(tournament.placements); } if (format === 'SE') { calculateSERankings(); // Cup and Plate: the Plate orders the Cup's round 1 losers (Docs/CUP-AND-PLATE.md) if (typeof Plate !== 'undefined') Plate.place(tournament.placements); return; } if (format === 'GROUPS') { // Derived from the cups as they stand (Docs/GROUPS-AND-CUPS.md, Placings) tournament.placements = typeof Groups !== 'undefined' ? Groups.placements() : {}; return; } if (format === 'SWISS') { // The top four's knockout and the table (Docs/SWISS.md, Placings) tournament.placements = typeof Swiss !== 'undefined' ? Swiss.placements() : {}; return; } if (bracketSize === 8) { calculate8PlayerRankings(); } else if (bracketSize === 16) { calculate16PlayerRankings(); } else if (bracketSize === 32) { calculate32PlayerRankings(); } else { console.warn(`Ranking calculation not implemented for ${bracketSize}-player bracket`); } console.log('Final tournament placements:', tournament.placements); } /** * CALCULATE 8-PLAYER RANKINGS */ function calculate8PlayerRankings() { console.log('Calculating 8-player rankings...'); // 4th place: BS-3-1 loser const bs31 = matches.find(m => m.id === 'BS-3-1'); if (bs31?.completed && bs31.loser?.id) { tournament.placements[String(bs31.loser.id)] = 4; console.log(`4th place: ${bs31.loser.name}`); } // 5th-6th place: BS-2-1 and BS-2-2 losers const bs21 = matches.find(m => m.id === 'BS-2-1'); const bs22 = matches.find(m => m.id === 'BS-2-2'); if (bs21?.completed && bs21.loser?.id) { tournament.placements[String(bs21.loser.id)] = 5; // Will display as "5th-6th" console.log(`5th-6th place: ${bs21.loser.name}`); } if (bs22?.completed && bs22.loser?.id) { tournament.placements[String(bs22.loser.id)] = 5; // Same rank for tie console.log(`5th-6th place: ${bs22.loser.name}`); } // 7th-8th place: BS-1-1 and BS-1-2 losers const bs11 = matches.find(m => m.id === 'BS-1-1'); const bs12 = matches.find(m => m.id === 'BS-1-2'); if (bs11?.completed && bs11.loser?.id) { tournament.placements[String(bs11.loser.id)] = 7; // Will display as "7th-8th" console.log(`7th-8th place: ${bs11.loser.name}`); } if (bs12?.completed && bs12.loser?.id) { tournament.placements[String(bs12.loser.id)] = 7; // Same rank for tie console.log(`7th-8th place: ${bs12.loser.name}`); } console.log('✓ 8-player rankings calculated'); } /** * CALCULATE 16-PLAYER RANKINGS */ function calculate16PlayerRankings() { console.log('Calculating 16-player rankings...'); // 4th place: BS-5-1 loser const bs51 = matches.find(m => m.id === 'BS-5-1'); if (bs51?.completed && bs51.loser?.id) { tournament.placements[String(bs51.loser.id)] = 4; console.log(`4th place: ${bs51.loser.name}`); } // 5th-6th place: BS-4-1 and BS-4-2 losers const bs41 = matches.find(m => m.id === 'BS-4-1'); const bs42 = matches.find(m => m.id === 'BS-4-2'); if (bs41?.completed && bs41.loser?.id) { tournament.placements[String(bs41.loser.id)] = 5; console.log(`5th-6th place: ${bs41.loser.name}`); } if (bs42?.completed && bs42.loser?.id) { tournament.placements[String(bs42.loser.id)] = 5; console.log(`5th-6th place: ${bs42.loser.name}`); } // 7th-8th place: BS-3-1 and BS-3-2 losers const bs31 = matches.find(m => m.id === 'BS-3-1'); const bs32 = matches.find(m => m.id === 'BS-3-2'); if (bs31?.completed && bs31.loser?.id) { tournament.placements[String(bs31.loser.id)] = 7; console.log(`7th-8th place: ${bs31.loser.name}`); } if (bs32?.completed && bs32.loser?.id) { tournament.placements[String(bs32.loser.id)] = 7; console.log(`7th-8th place: ${bs32.loser.name}`); } // 9th-12th place: BS-2-1 to BS-2-4 losers const bs2Matches = ['BS-2-1', 'BS-2-2', 'BS-2-3', 'BS-2-4']; bs2Matches.forEach(matchId => { const match = matches.find(m => m.id === matchId); if (match?.completed && match.loser?.id) { tournament.placements[String(match.loser.id)] = 9; // All get rank 9 for "9th-12th" console.log(`9th-12th place: ${match.loser.name}`); } }); // 13th-16th place: BS-1-1 to BS-1-4 losers const bs1Matches = ['BS-1-1', 'BS-1-2', 'BS-1-3', 'BS-1-4']; bs1Matches.forEach(matchId => { const match = matches.find(m => m.id === matchId); if (match?.completed && match.loser?.id) { tournament.placements[String(match.loser.id)] = 13; // All get rank 13 for "13th-16th" console.log(`13th-16th place: ${match.loser.name}`); } }); console.log('✓ 16-player rankings calculated'); } /** * CALCULATE 32-PLAYER RANKINGS */ function calculate32PlayerRankings() { console.log('Calculating 32-player rankings...'); // 4th place: BS-7-1 loser const bs71 = matches.find(m => m.id === 'BS-7-1'); if (bs71?.completed && bs71.loser?.id) { tournament.placements[String(bs71.loser.id)] = 4; console.log(`4th place: ${bs71.loser.name}`); } // 5th-6th place: BS-6-1 and BS-6-2 losers const bs61 = matches.find(m => m.id === 'BS-6-1'); const bs62 = matches.find(m => m.id === 'BS-6-2'); if (bs61?.completed && bs61.loser?.id) { tournament.placements[String(bs61.loser.id)] = 5; console.log(`5th-6th place: ${bs61.loser.name}`); } if (bs62?.completed && bs62.loser?.id) { tournament.placements[String(bs62.loser.id)] = 5; console.log(`5th-6th place: ${bs62.loser.name}`); } // 7th-8th place: BS-5-1 and BS-5-2 losers const bs51 = matches.find(m => m.id === 'BS-5-1'); const bs52 = matches.find(m => m.id === 'BS-5-2'); if (bs51?.completed && bs51.loser?.id) { tournament.placements[String(bs51.loser.id)] = 7; console.log(`7th-8th place: ${bs51.loser.name}`); } if (bs52?.completed && bs52.loser?.id) { tournament.placements[String(bs52.loser.id)] = 7; console.log(`7th-8th place: ${bs52.loser.name}`); } // 9th-12th place: BS-4-1 to BS-4-4 losers const bs4Matches = ['BS-4-1', 'BS-4-2', 'BS-4-3', 'BS-4-4']; bs4Matches.forEach(matchId => { const match = matches.find(m => m.id === matchId); if (match?.completed && match.loser?.id) { tournament.placements[String(match.loser.id)] = 9; // All get rank 9 for "9th-12th" console.log(`9th-12th place: ${match.loser.name}`); } }); // 13th-16th place: BS-3-1 to BS-3-4 losers const bs3Matches = ['BS-3-1', 'BS-3-2', 'BS-3-3', 'BS-3-4']; bs3Matches.forEach(matchId => { const match = matches.find(m => m.id === matchId); if (match?.completed && match.loser?.id) { tournament.placements[String(match.loser.id)] = 13; // All get rank 13 for "13th-16th" console.log(`13th-16th place: ${match.loser.name}`); } }); // 17th-24th place: BS-2-1 to BS-2-8 losers const bs2Matches = ['BS-2-1', 'BS-2-2', 'BS-2-3', 'BS-2-4', 'BS-2-5', 'BS-2-6', 'BS-2-7', 'BS-2-8']; bs2Matches.forEach(matchId => { const match = matches.find(m => m.id === matchId); if (match?.completed && match.loser?.id) { tournament.placements[String(match.loser.id)] = 17; // All get rank 17 for "17th-24th" console.log(`17th-24th place: ${match.loser.name}`); } }); // 25th-32nd place: BS-1-1 to BS-1-8 losers const bs1Matches = ['BS-1-1', 'BS-1-2', 'BS-1-3', 'BS-1-4', 'BS-1-5', 'BS-1-6', 'BS-1-7', 'BS-1-8']; bs1Matches.forEach(matchId => { const match = matches.find(m => m.id === matchId); if (match?.completed && match.loser?.id) { tournament.placements[String(match.loser.id)] = 25; // All get rank 25 for "25th-32nd" console.log(`25th-32nd place: ${match.loser.name}`); } }); console.log('✓ 32-player rankings calculated'); } /** * CHECK IF PLAYER IS WALKOVER (for auto-advancement) */ function isWalkover(player) { if (!player) return false; return player.isBye === true || player.name === 'Walkover' || (player.id && player.id.toString().startsWith('walkover-')); } /** * AUTO-ADVANCEMENT: Real player vs Walkover = automatic win * FIXED: Handle walkover vs walkover matches */ function shouldAutoAdvance(match) { if (!match || match.completed) return false; if (!match.player1 || !match.player2) return false; // Never auto-advance TBD vs anything (TBD = waiting for opponent) if (match.player1.name === 'TBD' || match.player2.name === 'TBD') { return false; } const p1IsWalkover = isWalkover(match.player1); const p2IsWalkover = isWalkover(match.player2); // Auto-advance Real vs Walkover OR Walkover vs Walkover return (p1IsWalkover && !p2IsWalkover) || (!p1IsWalkover && p2IsWalkover) || (p1IsWalkover && p2IsWalkover); } /** * Processes all pending walkover matches where one player is a BYE. * Automatically advances real players past walkover opponents. * Skipped during rebuild operations to prevent transaction corruption. * * @returns {void} * * @description * - Iterates through all matches looking for walkover conditions * - Auto-completes matches where one player is BYE and one is real * - Uses completion type 'AUTO' to distinguish from manual completions * - Protected against recursive calls and rebuild interference */ function processAutoAdvancements() { if (!matches || matches.length === 0) return; // Skip auto-advancements during rebuild to prevent transaction corruption if (window.rebuildInProgress || window.autoAdvancementsDisabled) { console.log('🚫 processAutoAdvancements blocked during rebuild'); return; } // Prevent recursive calls to avoid infinite loops if (window.processingAutoAdvancements) { console.log('🚫 processAutoAdvancements already running, skipping to prevent infinite loop'); console.log('⚠️ Flag state check - processingAutoAdvancements:', window.processingAutoAdvancements); return; } // DEBUG: Log what triggered this auto-advancement console.log('⚡ processAutoAdvancements called - stack trace:', new Error().stack.substring(0, 500)); // Set flag to prevent recursive calls window.processingAutoAdvancements = true; try { let foundAdvancement = true; let iterations = 0; const maxIterations = 10; const autoAdvancedMatches = []; while (foundAdvancement && iterations < maxIterations) { foundAdvancement = false; iterations++; matches.forEach(match => { if (shouldAutoAdvance(match)) { // Determine automatic winner const p1IsWalkover = isWalkover(match.player1); const winnerPlayerNumber = p1IsWalkover ? 2 : 1; // Mark as auto-advanced and complete match.autoAdvanced = true; autoAdvancedMatches.push(match.id); completeMatch(match.id, winnerPlayerNumber, 0, 0, 'AUTO'); foundAdvancement = true; } }); } if (autoAdvancedMatches.length > 0) { console.log(`Auto-advanced: ${autoAdvancedMatches.join(', ')} (${autoAdvancedMatches.length} matches)`); } } finally { // Always clear flag, even if function exits early console.log('🧹 Clearing processingAutoAdvancements flag'); window.processingAutoAdvancements = false; } } /** * DEBUGGING: Reset stuck flags (can be called from browser console) */ function resetAutoAdvancementFlags() { console.log('🔧 Resetting auto-advancement flags'); window.processingAutoAdvancements = false; window.rebuildInProgress = false; window.autoAdvancementsDisabled = false; console.log('✅ Flags reset'); } // Make it globally available for debugging if (typeof window !== 'undefined') { window.resetAutoAdvancementFlags = resetAutoAdvancementFlags; } /** * WINNER SELECTION */ function selectWinnerClean(matchId, playerNumber) { return validateAndShowWinnerDialog(matchId, playerNumber); } /** * DEBUG FUNCTION: Show progression for a specific match */ function debugProgression(matchId) { if (!tournament || !tournament.bracketSize) { console.log('No tournament active'); return; } const progression = getProgressionTable(); const rule = progression?.[matchId]; if (rule) { console.log(`=== PROGRESSION FOR ${matchId} ===`); console.log(`Winner goes to: ${rule.winner?.[0]}.${rule.winner?.[1]}`); console.log(`Loser goes to: ${rule.loser?.[0]}.${rule.loser?.[1] || 'eliminated'}`); } else { console.log(`No progression rule for ${matchId}`); } } /** * DISABLE OLD PROGRESSION FUNCTIONS - Prevent conflicts with new system */ function disableOldProgressionSystem() { // Override old functions to prevent conflicts if (typeof window !== 'undefined') { window.advanceWinner = function () { console.log('Old advanceWinner disabled - using new lookup system'); }; window.advanceBacksideWinner = function () { console.log('Old advanceBacksideWinner disabled - using new lookup system'); }; window.dropFrontsideLoser = function () { console.log('Old dropFrontsideLoser disabled - using new lookup system'); }; window.processAutoAdvancementForMatch = function () { console.log('Old processAutoAdvancementForMatch disabled'); }; window.forceBacksideAutoAdvancement = function () { console.log('Old forceBacksideAutoAdvancement disabled'); }; } } // Disable old system immediately when this file loads disableOldProgressionSystem(); /** * Generates the tournament bracket structure with optimized player placement. * Places real players first, walkovers (BYEs) last, ensuring no walkover vs walkover. * * @returns {boolean} True if bracket generated successfully, false on validation failure * * @description * - Validates minimum 4 players, maximum 32 players * - All players must be marked as paid * - Determines bracket size (8, 16, or 32) based on player count * - Creates all match objects for frontside, backside, and grand final * - Triggers auto-advancements for initial walkover matches * - Sets tournament status to 'active' * * @example * // Generate bracket after registering players * if (generateCleanBracket()) { * console.log('Tournament started!'); * } */ /** * Validates player count and shows bracket generation confirmation dialog. * Entry point for bracket generation — called from format selection cards in Setup Actions. * * @param {'DE'|'SE'} [format='DE'] - Tournament format * @returns {false} Always returns false (prevents form submission) */ function generateCleanBracket(format) { format = format || 'DE'; if (!tournament) { alert('Please create a tournament first'); return false; } // Check if bracket already exists if (tournament.bracket && matches.length > 0) { showTournamentProgressWarning(); return false; } const paidPlayers = players.filter(p => p.paid); // Check for unpaid players const unpaidPlayers = players.filter(p => !p.paid); if (unpaidPlayers.length > 0) { alert('All players must be marked as paid to generate bracket. Go to Player Registration to update payment status or remove players.'); console.error(`Bracket generation blocked: ${unpaidPlayers.length} unpaid player(s) detected`); return false; } const formatInfo = typeof TOURNAMENT_FORMATS !== 'undefined' ? TOURNAMENT_FORMATS.find(f => f.id === format) : null; // Round Robin's limits follow its structure (one group: 3-8; groups and cups: 6-32) const limits = format === 'GROUPS' && typeof Groups !== 'undefined' ? Groups.limits() : formatInfo; const minPlayers = (limits && limits.minPlayers) || 4; if (limits && limits.maxPlayers && paidPlayers.length > limits.maxPlayers) { alert(`At most ${limits.maxPlayers} players can play ${formatInfo ? `a ${formatInfo.name} tournament` : 'this format'} as set up in Global Settings.`); return false; } if (paidPlayers.length < minPlayers) { alert(`At least ${minPlayers} paid players are required to draw ${formatInfo ? `a ${formatInfo.name} tournament` : 'the bracket'}.`); console.error(`Bracket generation blocked: fewer than ${minPlayers} paid players`); // HELP SYSTEM INTEGRATION if (typeof showHelpHint === 'function') { showHelpHint(`Need at least ${minPlayers} paid players to generate bracket. Add more players on Registration page.`, 5000); } return false; } // Double and single elimination: up to 48, the players above 32 through qualifiers const maxAll = format === 'GROUPS' ? 32 : (typeof Qualifiers !== 'undefined' ? Qualifiers.MAX_PLAYERS : 32); if (paidPlayers.length > maxAll) { alert(`Maximum ${maxAll} paid players supported. Please remove some players to generate bracket.`); console.error(`Bracket generation blocked: more than ${maxAll} paid players`); return false; } // Determine bracket size (format-aware: SE supports 2 and 4 player brackets; groups and cups: // the number of players drawn into the groups) const bracketSize = (format === 'GROUPS' || format === 'SWISS') ? paidPlayers.length : calculateBracketSize(paidPlayers.length, format); const byeCount = bracketSize - paidPlayers.length; // negative above 32: that many qualifiers // Show confirmation dialog with player list showBracketConfirmation(paidPlayers, bracketSize, byeCount, format); return false; } /** * Shows bracket generation confirmation dialog with player cards. * Stores the chosen format in pendingFormat for confirmBracketGeneration() to read. * * @param {Array} paidPlayers - Array of paid player objects * @param {number} bracketSize - Bracket size (2, 4, 8, 16, 32) * @param {number} byeCount - Number of byes * @param {'DE'|'SE'} format - Tournament format */ function showBracketConfirmation(paidPlayers, bracketSize, byeCount, format) { pendingFormat = format; // Sidebar — bracket summary const groups = format === 'GROUPS'; const swiss = format === 'SWISS' && typeof Swiss !== 'undefined'; const single = groups && Groups.configSettings().structure === 'single'; // Cup and Plate: the Plate is half the Cup (picked at the draw) const withPlate = format === 'SE' && bracketSize >= 8 && typeof Plate !== 'undefined' && Plate.wanted(); const formatLabel = swiss ? 'Swiss' : groups ? (single ? 'Round Robin, one group' : 'Round Robin, groups and cups') : withPlate ? 'Cup and Plate' : format === 'SE' ? 'Single Elimination' : 'Double Elimination'; document.getElementById('bracketConfirmTitle').textContent = swiss ? 'Draw Swiss' : groups ? (single ? 'Draw the Group' : 'Draw the Groups') : `Generate ${formatLabel} Bracket`; document.getElementById('bracketConfirmName').textContent = (tournament && tournament.name) || '-'; document.getElementById('bracketConfirmFormat').textContent = formatLabel; const sizeLabel = document.getElementById('bracketConfirmSizeLabel'); if (sizeLabel) sizeLabel.textContent = swiss ? 'Rounds' : groups ? (single ? 'Group' : 'Groups') : 'Bracket Size'; const swissRounds = swiss ? Swiss.roundsFor(paidPlayers.length) : 0, swissTop4 = swiss && Swiss.configSettings().finish === 'top4'; document.getElementById('bracketConfirmSize').textContent = swiss ? `${swissRounds}${swissTop4 ? ', then the top 4' : ''}` : groups ? Groups.describeSizes(paidPlayers.length) : withPlate ? `${bracketSize} + Plate of ${bracketSize / 2}` : bracketSize; document.getElementById('bracketConfirmPlayerCount').textContent = paidPlayers.length; const desc = document.getElementById('bracketConfirmDesc'); if (desc) desc.textContent = swiss ? `${swissRounds} rounds; each pairs players with the same record who haven't met${typeof Seeding !== 'undefined' && Seeding.forGroups && Seeding.forGroups(paidPlayers) ? ' (round 1 by ranking: the top half against the bottom half)' : ' (round 1 at random)'}. ${swissTop4 ? 'Then the top four play semifinals, a bronze final and a final.' : 'The table decides the placings.'}` : single ? `Everybody plays everybody, in a fixed order${Groups.seededDraw(paidPlayers) ? ' set by the ranking' : ' drawn at random'}. The table decides the placings.` : groups ? `These players will be drawn into groups${Groups.seededDraw(paidPlayers) ? ', by ranking' : ' at random'}. Everybody plays everybody in their group; ${Groups.configSettings().cupEntry === 'half' ? 'then the top half across the groups plays the A cup, the rest the B cup' : 'the top two of each group go on to the A cup, the rest to the B cup'}.` : paidPlayers.length > 32 ? `${paidPlayers.length - 32} qualifier matches decide the last places in the 32-player bracket: ${2 * (paidPlayers.length - 32)} players are drawn into them, ${64 - paidPlayers.length} go straight into round 1. Lose a qualifier and you are not qualified (33rd); nothing in a qualifier counts.` : withPlate ? `These players will be placed into the bracket, the Cup. Every round 1 loser goes on to the Plate, a second knockout of ${bracketSize / 2}, so everyone plays at least two matches.` : 'These players will be placed into the bracket. Make sure all players are registered before proceeding.'; // Byes field is conditional — show only when there are byes; above 32 the same field says how // many qualifiers there are (the mirror of byes) const byesLabel = document.getElementById('bracketConfirmByesLabel'); const byesValue = document.getElementById('bracketConfirmByes'); if (byeCount !== 0 && !groups && !swiss) { byesLabel.style.display = ''; byesValue.style.display = ''; byesLabel.textContent = byeCount > 0 ? 'Byes' : 'Qualifiers'; byesValue.textContent = byeCount > 0 ? byeCount : `${-byeCount} (${-2 * byeCount} players)`; } else { byesLabel.style.display = 'none'; byesValue.style.display = 'none'; } // Render read-only player chips, sorted alphabetically. Safe DOM construction // (textContent for names) — no innerHTML interpolation. const playersEl = document.getElementById('bracketConfirmPlayers'); playersEl.replaceChildren(); const sorted = [...paidPlayers].sort((a, b) => a.name.localeCompare(b.name)); sorted.forEach(p => { const chip = document.createElement('div'); chip.className = 'bracket-confirm-player'; chip.textContent = p.name; playersEl.appendChild(chip); }); pushDialog('bracketConfirmModal', null, true); // Focus the Cancel button after dialog is shown const cancelBtn = document.querySelector('#bracketConfirmModal .btn:not(.btn-success)'); if (cancelBtn) cancelBtn.focus(); } /** * Executes bracket generation after user confirmation. * Reads pendingFormat (set by showBracketConfirmation) and stores it on the tournament object. */ function confirmBracketGeneration() { popDialog(); const format = pendingFormat; const paidPlayers = players.filter(p => p.paid); // Groups and cups: the groups are drawn here, the cups later from the groups' results if (format === 'GROUPS') { drawGroups(paidPlayers); afterDraw(); return true; } // Swiss: round 1 is drawn here, every later round when the round before it is played if (format === 'SWISS') { drawSwiss(paidPlayers); afterDraw(); return true; } // Determine bracket size (format-aware) const bracketSize = calculateBracketSize(paidPlayers.length, format); // Create optimized bracket: real players first, walkovers strategically placed console.log(`Generating ${bracketSize}-player ${format} bracket for ${paidPlayers.length} players`); // Seeded when the operator asked for it in Match Controls (js/seeding.js); otherwise null let seeding = typeof Seeding !== 'undefined' ? Seeding.forDraw(paidPlayers, bracketSize) : null; // Qualifiers (33-48 players): the straight-in players are drawn into the 32 places as if there // were byes, and each gap becomes the place of a qualifier's winner (Docs/QUALIFIERS.md) let qualified = null; if (paidPlayers.length > 32 && typeof Qualifiers !== 'undefined') { qualified = Qualifiers.split(paidPlayers, seeding); seeding = qualified.seeding; } let bracket = createOptimizedBracketV2(qualified ? qualified.straightIn : paidPlayers, bracketSize, seeding); if (!bracket) { alert('Unable to generate a valid bracket without bye vs bye in Round 1. Please add more players or try again.'); console.error('Bracket generation failed: createOptimizedBracketV2 returned null'); return false; } let made = null; if (qualified) { const legs = format === 'SE' ? config.legs.seRegularRounds : config.legs.regularRounds; made = Qualifiers.fromBracket(bracket, qualified.qualifierPlayers, legs || 3, 1); bracket = made.bracket; } // Store bracket info tournament.bracket = bracket; tournament.bracketSize = bracketSize; tournament.format = format; if (seeding) tournament.seeding = seeding.record; // who was seeded, and from what (absent = a random draw) if (made) tournament.qualifiers = made.qualifiers; else delete tournament.qualifiers; delete tournament.notQualified; // Cup and Plate: the switch at the draw (single elimination, a Cup of 8 or more) const plate = format === 'SE' && bracketSize >= 8 && typeof Plate !== 'undefined' && Plate.wanted(); if (plate) tournament.plate = { size: bracketSize / 2 }; else delete tournament.plate; tournament.status = 'active'; // Generate all match structures with clean TBD placeholders generateAllMatches(bracket, bracketSize); if (made) { // the qualifiers go first (Q1… are numbered 1…), the bracket's matches after them matches.forEach(m => { m.numericId += made.made.length; }); matches.unshift(...made.made); } if (plate) { // the Plate's matches after the Cup's; round 1 fills them as it is played matches.push(...Plate.make(bracketSize, Math.max(0, ...matches.map(m => m.numericId || 0)) + 1).made); } // Process initial auto-advancements (real vs walkover) // Skip during rebuild to prevent transaction corruption if (!window.rebuildInProgress) { processAutoAdvancements(); } console.log(`✓ Clean bracket generated: ${bracketSize} positions, ${paidPlayers.length} real players`); afterDraw(); return true; } /** * After a draw (any format): save, draw the bracket page, refresh results, Match Controls and * Registration, and go to the bracket page. * @returns {void} */ function afterDraw() { // Save and render if (typeof saveTournament === 'function') { saveTournament(); } if (typeof renderBracket === 'function') { renderBracket(); } // Refresh results table immediately after bracket generation if (typeof displayResults === 'function') { displayResults(); } // Refresh Match Controls if it's open to show new ACTIVE state const modal = document.getElementById('matchCommandCenterModal'); if (modal && (modal.style.display === 'flex' || modal.style.display === 'block') && typeof showMatchCommandCenter === 'function') { setTimeout(() => { showMatchCommandCenter(); }, 100); } if (typeof showPage === 'function') { showPage('tournament'); } // Update Registration page layout (tournament now active) if (typeof updateRegistrationPageLayout === 'function') { updateRegistrationPageLayout(); } // HELP SYSTEM INTEGRATION if (typeof onBracketGenerated === 'function') { onBracketGenerated(); } } /** A player as a match slot holds it (as advancePlayer() places one). */ const _slotPlayer = p => ({ id: p.id, name: p.name, paid: p.paid, stats: p.stats }); /** * GROUPS AND CUPS: draw the groups and make every group match, in each group's fixed order * (GROUP_SCHEDULES) with its planned referee. Who goes into which group is Groups.drawGroups() * (snake order, by ranking when seeding is on). Sets the tournament's format, draw and status; * the caller saves. Docs/GROUPS-AND-CUPS.md * @param {Player[]} paid - everyone in the draw (all paid) * @returns {void} */ function drawGroups(paid) { const draw = Groups.drawGroups(paid); matches = []; let numericId = 1; // One group played twice (a double round robin): the whole schedule again as the return round, // in the same order with player 1 and player 2 swapped, the same planned referees const twice = draw.settings && draw.settings.structure === 'single' && draw.settings.meetings === 2; draw.list.forEach(group => { const once = roundRobinSchedule(group.players.length); const schedule = twice ? once.concat(once.map(([a, b, r]) => [b, a, r])) : once; schedule.forEach(([a, b, r], i) => { const ref = r ? group.players[r - 1] : null; matches.push({ id: `${group.name}-${i + 1}`, numericId: numericId++, round: group.players.length === 4 ? Math.ceil((i + 1) / 2) : i + 1, side: 'group', group: group.name, player1: _slotPlayer(group.players[a - 1]), player2: _slotPlayer(group.players[b - 1]), winner: null, loser: null, lane: null, legs: (config.legs && config.legs.groupMatches) || 3, referee: null, plannedReferee: ref ? { player: ref.id } : null, active: false, completed: false, positionInRound: i, ...(i >= once.length ? { returnRound: true } : {}) // a double round robin's second meeting }); }); }); tournament.bracket = draw.order; // the players in draw order: "the draw is made" tournament.bracketSize = paid.length; tournament.format = 'GROUPS'; tournament.groups = { list: draw.list.map(g => ({ name: g.name, players: g.players.map(p => p.id) })), settings: draw.settings }; delete tournament.cups; if (draw.seeding) tournament.seeding = draw.seeding; else delete tournament.seeding; tournament.status = 'active'; console.log(`✓ Groups drawn: ${draw.list.map(g => `${g.name} (${g.players.length})`).join(', ')}; ${matches.length} group matches`); } /** * GROUPS AND CUPS: draw the cups once every group match is played. The fields and seeds come * from the group tables (Groups.cupFields()); each cup is single elimination seeded "All" * (placeSeededPlayers(), top seed v bottom seed, byes to the best seeds), its matches the SE * structure with cup IDs (cupMatchId). Recorded as a DRAW_CUPS transaction, which undoCupDraw() * removes again while no cup result has been entered. Docs/GROUPS-AND-CUPS.md * @param {boolean} playB - play the B cup (needs at least two players) * @returns {boolean} true when the cups were drawn */ function drawCups(playB) { if (getFormat() !== 'GROUPS' || tournament.cups || tournament.readOnly) return false; if (!Groups.allGroupsDone()) { alert('Every group match must be played before the cups are drawn.'); return false; } // the B cup needs at least two players; without it the A cup takes the top two of each group const playsB = !!playB && Groups.cupFields(true).B.length >= 2; const fields = Groups.cupFields(playsB); const cups = { bCup: playsB, A: null, B: null }; let numericId = Math.max(0, ...matches.map(m => m.numericId || 0)) + 1; const made = []; ['A', 'B'].forEach(cup => { if (cup === 'B' && !cups.bCup) return; const size = calculateBracketSize(fields[cup].length, 'SE'); // Round Robin → Group rematches: Avoid reorders the field so round 1 pairs players from different groups const field = (Groups.settings().rematches === 'avoid' ? Groups.avoidRematches(fields[cup], size) : fields[cup]).map(_slotPlayer); const bracket = placeSeededPlayers(field, size, field, true); cups[cup] = { size, seeds: field.map(p => p.id) }; calculateCleanBracketStructure(size, 'SE').frontside.forEach((roundInfo, roundIndex) => { for (let i = 0; i < roundInfo.matches; i++) { const seId = `FS-${roundInfo.round}-${i + 1}`; // the cups' own lengths (Global Settings → Match length → Groups and cups) const legs = isSEFinalMatch(seId, size) ? config.legs.cupFinal : isSEBronzeMatch(seId, size) ? config.legs.cupBronze : isSESemifinal(seId, size) ? config.legs.cupSemifinal : config.legs.cupRounds; const tbd = n => createTBDPlayer(`${cup.toLowerCase()}-${roundInfo.round}-${i}-${n}`); made.push({ id: cupMatchId(cup, seId, size), seId, numericId: numericId++, round: roundInfo.round, side: 'cup', cup, player1: roundIndex === 0 ? bracket[i * 2] : tbd(1), player2: roundIndex === 0 ? bracket[i * 2 + 1] : tbd(2), winner: null, loser: null, lane: null, legs: legs || 3, referee: null, plannedReferee: null, active: false, completed: false, positionInRound: i }); } }); Groups.planCupReferees(made.filter(m => m.cup === cup), size); }); matches.push(...made); tournament.cups = cups; if (!window.rebuildInProgress) { saveTransaction({ id: generateTransactionId(), type: 'DRAW_CUPS', description: `Cups drawn: A cup ${cups.A.seeds.length} players${cups.B ? `, B cup ${cups.B.seeds.length} players` : ', no B cup'}`, timestamp: new Date().toISOString(), cups: JSON.parse(JSON.stringify(cups)) }); processAutoAdvancements(); // round-1 walkovers, as in any draw } saveTournament(); if (typeof renderBracket === 'function') renderBracket(); if (typeof displayResults === 'function') displayResults(); console.log(`✓ Cups drawn: A ${cups.A.size}${cups.B ? `, B ${cups.B.size}` : ', no B cup'}`); return true; } /** * SWISS: draw the tournament: the players in draw order (ranked best first when seeding is ticked, * the rest at random; otherwise all at random), the rounds and the finish from the settings for this * draw, and round 1. Sets the format, draw and status; the caller saves. Docs/SWISS.md * @param {Player[]} paid - everyone in the draw (all paid) * @returns {void} */ function drawSwiss(paid) { const shuffle = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; }; const seeded = typeof Seeding !== 'undefined' && Seeding.forGroups ? Seeding.forGroups(paid) : null; const order = seeded ? seeded.order.concat(shuffle(paid.filter(p => !seeded.order.includes(p)))) : shuffle(paid); const cfg = Swiss.configSettings(); matches = []; tournament.bracket = order; // the players in draw order: "the draw is made" tournament.bracketSize = paid.length; tournament.format = 'SWISS'; tournament.swiss = { rounds: Swiss.roundsFor(paid.length), finish: cfg.finish, drawn: [], knockout: false, seeded: !!seeded }; ['groups', 'cups', 'plate', 'qualifiers', 'notQualified'].forEach(k => delete tournament[k]); if (seeded) tournament.seeding = seeded.record; else delete tournament.seeding; tournament.status = 'active'; drawSwissRound(1, order.map(p => String(p.id))); console.log(`✓ Swiss drawn: ${paid.length} players, ${tournament.swiss.rounds} rounds, finish ${cfg.finish}`); } /** * SWISS: draw round r (Swiss.pairRound()): its matches, R{r}-1…, and its bye; recorded as a * DRAW_SWISS_ROUND transaction from round 2 on (round 1 goes with the draw). * @param {number} r * @param {string[]} [order] - round 1: player ids in draw order * @returns {void} */ function drawSwissRound(r, order) { const draw = Swiss.pairRound(r, order); let numericId = Math.max(0, ...matches.map(m => m.numericId || 0)) + 1; const playerById = id => _slotPlayer(players.find(p => String(p.id) === String(id)) || { id, name: '?' }); draw.pairs.forEach(([a, b], i) => matches.push({ id: `R${r}-${i + 1}`, numericId: numericId++, round: r, side: 'swiss', player1: playerById(a), player2: playerById(b), winner: null, loser: null, lane: null, legs: (config.legs && config.legs.swissRounds) || 3, referee: null, active: false, completed: false, positionInRound: i })); tournament.swiss.drawn.push({ round: r, byes: draw.byes }); if (r > 1 && !window.rebuildInProgress) { saveTransaction({ id: generateTransactionId(), type: 'DRAW_SWISS_ROUND', round: r, description: `Swiss round ${r} drawn${draw.byes.length ? `, bye: ${draw.byes.map(id => (players.find(p => String(p.id) === id) || {}).name).join(', ')}` : ''}`, timestamp: new Date().toISOString() }); } } /** * SWISS: draw the top four after the last round: the 4-player single elimination with its IDs * renamed (K-SF1: 1st v 4th, K-SF2: 2nd v 3rd, K-B, K-F); a DRAW_SWISS_KNOCKOUT transaction. * @returns {void} */ function drawSwissKnockout() { const top = Swiss.table().slice(0, 4).map(r => _slotPlayer(players.find(p => String(p.id) === r.id) || r.player)); const seats = { 'FS-1-1': [top[0], top[3]], 'FS-1-2': [top[1], top[2]] }; let numericId = Math.max(0, ...matches.map(m => m.numericId || 0)) + 1; const L = config.legs || {}; Object.keys(SE_MATCH_PROGRESSION[4]).forEach((seId, i) => { const id = cupMatchId('K', seId, 4); const [p1, p2] = seats[seId] || [createTBDPlayer(`k-${i}-1`), createTBDPlayer(`k-${i}-2`)]; matches.push({ id, seId, numericId: numericId++, round: +seId.split('-')[1], side: 'swissko', player1: p1, player2: p2, winner: null, loser: null, lane: null, legs: (id === 'K-F' ? L.swissFinal : id === 'K-B' ? L.swissBronze : L.swissSemifinal) || 3, referee: null, active: false, completed: false, positionInRound: i }); }); tournament.swiss.knockout = true; if (!window.rebuildInProgress) { saveTransaction({ id: generateTransactionId(), type: 'DRAW_SWISS_KNOCKOUT', description: `Swiss top four drawn: ${top.map(p => p.name).join(', ')}`, timestamp: new Date().toISOString() }); } } /** * SWISS: after a Swiss round result: when it was the round's last, draw the next round, or after * the last round the top four (with Finish: Semifinals and final). Docs/SWISS.md * @param {object} match - the Swiss match just completed * @returns {void} */ function swissAfterResult(match) { if (typeof Swiss === 'undefined' || !tournament.swiss || !Swiss.roundDone(match.round)) return; if (match.round !== Swiss.drawnRounds()) return; // only the latest round draws the next if (match.round < tournament.swiss.rounds) drawSwissRound(match.round + 1); else if (tournament.swiss.finish === 'top4' && !tournament.swiss.knockout) drawSwissKnockout(); } /** * CREATE OPTIMIZED BRACKET: Distributed random seeding to avoid bye-vs-bye in FS Round 1 * - Step 1: Randomly distribute BYEs across FS-R1 matches (max 1 per match, random slot) * - Step 2: Fill remaining slots with shuffled real players * * Mathematical guarantee: min players = bracketSize/2, so max BYEs = bracketSize/2 = numMatches * This ensures we can always place max 1 BYE per match without BYE-vs-BYE scenarios * * Works for bracketSize 8, 16, 32 * * With `seeding` (js/seeding.js), steps 1 and 2 are replaced by placeSeededPlayers(): the seeds * are kept apart and get the byes, everyone else is still random. Without it the draw is exactly * as before. * * @param {object[]} players - the players going into the draw * @param {number} bracketSize * @param {{seeds: object[], all: boolean}|null} [seeding] - seeds are player objects from `players`, best first; `all`: the mirror draw * @returns {Array|null} the bracket positions (position 2n and 2n+1 meet in FS-1-(n+1)), or null if invalid */ function createOptimizedBracketV2(players, bracketSize, seeding = null) { // Defensive checks if (!Array.isArray(players)) { console.error('createOptimizedBracketV2: players must be an array'); return null; } if (![4, 8, 16, 32].includes(bracketSize)) { console.warn(`createOptimizedBracketV2: unexpected bracketSize=${bracketSize}; proceeding with distributed seeding`); } const P = players.length; const K = bracketSize; const numWalkovers = K - P; const numMatches = K / 2; console.log(`Creating bracket: ${P} real players, ${numWalkovers} walkovers, size=${K}`); // Assertion: numWalkovers <= numMatches (guaranteed by min 4 player requirement) if (numWalkovers > numMatches) { console.error(`IMPOSSIBLE: ${numWalkovers} walkovers exceeds ${numMatches} matches`); return null; } let bracket; if (seeding && Array.isArray(seeding.seeds) && seeding.seeds.length >= 2) { bracket = placeSeededPlayers(players, K, seeding.seeds, !!seeding.all); } else { // Shuffle players to ensure randomness const shuffledPlayers = [...players].sort(() => Math.random() - 0.5); // Initialize bracket with null slots bracket = new Array(K).fill(null); const matchesWithBye = new Set(); // Step 1: Randomly distribute BYEs across matches let byesPlaced = 0; while (byesPlaced < numWalkovers) { // Pick a random match that doesn't have a BYE yet const matchIndex = Math.floor(Math.random() * numMatches); if (matchesWithBye.has(matchIndex)) continue; matchesWithBye.add(matchIndex); // Randomly choose player1 (0) or player2 (1) slot within this match const slotInMatch = Math.random() < 0.5 ? 0 : 1; const bracketPosition = matchIndex * 2 + slotInMatch; bracket[bracketPosition] = createWalkoverPlayer(bracketPosition); byesPlaced++; } // Step 2: Fill remaining slots with shuffled players let playerIndex = 0; for (let i = 0; i < K; i++) { if (bracket[i] === null) { bracket[i] = shuffledPlayers[playerIndex++]; } } } // Sanity validation: ensure no FS-1 bye-vs-bye (should be impossible with this algorithm) const firstRoundMatches = K / 2; let invalidPairs = 0; for (let m = 0; m < firstRoundMatches; m++) { const a = bracket[m * 2]; const b = bracket[m * 2 + 1]; const aIsBye = isWalkover ? isWalkover(a) : a?.isBye === true || a?.name === 'Walkover'; const bIsBye = isWalkover ? isWalkover(b) : b?.isBye === true || b?.name === 'Walkover'; if (aIsBye && bIsBye) invalidPairs++; } if (invalidPairs > 0) { // This should NEVER happen with distributed seeding console.error(`ALGORITHM BUG: ${invalidPairs} bye-vs-bye pairs detected in FS-1!`); return null; } console.log(`✓ Distributed seeding completed: ${numWalkovers} BYEs randomly placed across ${numMatches} matches`); return bracket; } /** * PLACE A SEEDED DRAW: the bracket positions when the best players are seeded. * * The seeds are spread over equal segments of the bracket (the number of seeds rounded up to * a power of two). Seed 1 is at the very top and seed 2 at the very bottom (the two halves); * seeds 3 and 4 take the two middle quarters in random order; seeds 5-8 take what is left of * the eighths in random order, and so on, so seeds never meet in round 1. Inside its segment a * seed sits where the draw puts it (seed 1 and 2 at the ends). The best seeds get the byes, in * rank order; any byes beyond the seeds go to matches without a seed first. Everyone else fills * what is left, shuffled. * * With `all`, every ranked player is a seed and round 1 is the mirror draw: seed 1 meets the last * seed, seed 2 the second-last, and so on. The players without a ranking take the leftover * seed numbers at random, and the numbers beyond the players are the byes, which are therefore * the top seeds' (no unseeded player gets a bye while there is a seed without one). * * Only the layout: who the seeds are is decided in js/seeding.js, and what happens after * round 1 is the progression tables'. * * @param {object[]} players - everyone going into the draw (the seeds among them) * @param {number} K - bracket size * @param {object[]} seeds - the seeds, best first; at least 2 * @param {boolean} all - every ranked player is seeded: the mirror draw * @returns {Array} K positions, byes as walkover players */ function placeSeededPlayers(players, K, seeds, all) { const numMatches = K / 2; const numWalkovers = K - players.length; const rand = n => Math.floor(Math.random() * n); const shuffle = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = rand(i + 1); [b[i], b[j]] = [b[j], b[i]]; } return b; }; // the segment (0 = top) of each seed, best first, when the bracket is cut into n segments const segmentsFor = n => { let segment = [0, 1]; for (let m = 2; m < n; m *= 2) { const split = segment.map((s, i) => i === 0 ? 2 * s : i === 1 ? 2 * s + 1 : 2 * s + rand(2)); const free = []; for (let s = 0; s < 2 * m; s++) if (!split.includes(s)) free.push(s); segment = split.concat(shuffle(free)); } return segment; }; const bracket = new Array(K).fill(null); if (all) { // seed numbers 1..K: the ranked players, then the others at random, then the byes const field = seeds.concat(shuffle(players.filter(p => !seeds.includes(p)))); const at = i => field[i] || null; const matchOf = segmentsFor(numMatches); for (let i = 0; i < numMatches; i++) { const slot = i === 0 ? 0 : i === 1 ? 1 : rand(2); const top = 2 * matchOf[i] + slot, low = 2 * matchOf[i] + 1 - slot; bracket[top] = at(i); bracket[low] = at(K - 1 - i) || createWalkoverPlayer(low); } return bracket; } let n = 2; while (n < seeds.length) n *= 2; const segment = segmentsFor(n); const size = K / n; const seedMatch = new Set(); seeds.forEach((seed, i) => { const position = i === 0 ? 0 : i === 1 ? K - 1 : segment[i] * size + rand(size); bracket[position] = seed; seedMatch.add(Math.floor(position / 2)); }); // byes: one per match, in the slot the seed (if any) is not in const byeMatch = new Set(); let byes = numWalkovers; const placeBye = m => { const free = [2 * m, 2 * m + 1].filter(p => bracket[p] === null); const position = free[rand(free.length)]; bracket[position] = createWalkoverPlayer(position); byeMatch.add(m); byes--; }; for (const seed of seeds) { if (byes <= 0) break; placeBye(Math.floor(bracket.indexOf(seed) / 2)); } const open = [...Array(numMatches).keys()].filter(m => !byeMatch.has(m)); shuffle(open.filter(m => !seedMatch.has(m))).concat(shuffle(open.filter(m => seedMatch.has(m)))) .forEach(m => { if (byes > 0) placeBye(m); }); const others = shuffle(players.filter(p => !seeds.includes(p))); let next = 0; for (let i = 0; i < K; i++) if (bracket[i] === null) bracket[i] = others[next++]; return bracket; } /** * CREATE WALKOVER PLAYER OBJECT */ function createWalkoverPlayer(index) { return { id: `walkover-${index}`, name: 'Walkover', isBye: true }; } /** * GENERATE ALL MATCHES WITH CLEAN TBD PLACEHOLDERS */ function generateAllMatches(bracket, bracketSize) { matches = []; // Clear existing matches const format = getFormat(); const structure = calculateCleanBracketStructure(bracketSize); let matchId = 1; console.log(`Generating ${format} frontside matches...`); generateFrontsideMatches(bracket, structure, matchId); if (format === 'DE') { matchId = matches.length + 1; console.log('Generating backside matches...'); generateBacksideMatches(structure, matchId); matchId = matches.length + 1; console.log('Generating final matches...'); generateFinalMatches(matchId); } // SE bronze + final rounds are included in the frontside structure // (added by calculateCleanBracketStructure), so no extra generation needed console.log(`✓ Generated ${matches.length} ${format} matches total`); } /** * CALCULATE CLEAN BRACKET STRUCTURE (rounds and matches per round) */ function calculateCleanBracketStructure(bracketSize, format = getFormat()) { // SE structures are fully hardcoded — bracketSize equals total match count. // Last natural frontside round (1 match) is the bronze final; one extra round // is added for the championship final. (A groups and cups tournament asks for 'SE' for its cups.) if (format === 'SE') { const seStructures = { 4: { frontsideRounds: 2, frontside: [{ round: 1, matches: 2 }, { round: 2, matches: 1 }, { round: 3, matches: 1 }], backside: [] }, 8: { frontsideRounds: 3, frontside: [{ round: 1, matches: 4 }, { round: 2, matches: 2 }, { round: 3, matches: 1 }, { round: 4, matches: 1 }], backside: [] }, 16: { frontsideRounds: 4, frontside: [{ round: 1, matches: 8 }, { round: 2, matches: 4 }, { round: 3, matches: 2 }, { round: 4, matches: 1 }, { round: 5, matches: 1 }], backside: [] }, 32: { frontsideRounds: 5, frontside: [{ round: 1, matches: 16 }, { round: 2, matches: 8 }, { round: 3, matches: 4 }, { round: 4, matches: 2 }, { round: 5, matches: 1 }, { round: 6, matches: 1 }], backside: [] } }; return seStructures[bracketSize]; } // DE: derive frontside dynamically; backside is hardcoded per bracket size. const frontsideRounds = Math.ceil(Math.log2(bracketSize)); const frontside = []; for (let round = 1; round <= frontsideRounds; round++) { frontside.push({ round, matches: Math.pow(2, frontsideRounds - round) }); } let backside = []; if (bracketSize === 8) { backside = [ { round: 1, matches: 2 }, { round: 2, matches: 2 }, { round: 3, matches: 1 } ]; } else if (bracketSize === 16) { backside = [ { round: 1, matches: 4 }, { round: 2, matches: 4 }, { round: 3, matches: 2 }, { round: 4, matches: 2 }, { round: 5, matches: 1 } ]; } else if (bracketSize === 32) { backside = [ { round: 1, matches: 8 }, { round: 2, matches: 8 }, { round: 3, matches: 4 }, { round: 4, matches: 4 }, { round: 5, matches: 2 }, { round: 6, matches: 2 }, { round: 7, matches: 1 } ]; } return { frontside, backside, frontsideRounds }; } /** * GENERATE FRONTSIDE MATCHES */ function generateFrontsideMatches(bracket, structure, startId) { let numericId = startId; structure.frontside.forEach((roundInfo, roundIndex) => { for (let matchIndex = 0; matchIndex < roundInfo.matches; matchIndex++) { let player1, player2; if (roundIndex === 0) { // First round: use actual players from bracket const playerIndex = matchIndex * 2; player1 = bracket[playerIndex] || createTBDPlayer(`fs-1-${matchIndex}-1`); player2 = bracket[playerIndex + 1] || createTBDPlayer(`fs-1-${matchIndex}-2`); } else { // Later rounds: TBD players (winners from previous rounds) player1 = createTBDPlayer(`fs-${roundInfo.round}-${matchIndex}-1`); player2 = createTBDPlayer(`fs-${roundInfo.round}-${matchIndex}-2`); } // Determine leg count based on match type let legCount; const matchId = `FS-${roundInfo.round}-${matchIndex + 1}`; const format = getFormat(); if (format === 'SE') { if (isSEFinalMatch(matchId, tournament.bracketSize)) { legCount = config.legs.seFinal; } else if (isSEBronzeMatch(matchId, tournament.bracketSize)) { legCount = config.legs.seBronze; } else if (isSESemifinal(matchId, tournament.bracketSize)) { legCount = config.legs.seSemifinal; } else if (isSEQuarterfinal(matchId, tournament.bracketSize)) { legCount = config.legs.seQuarterfinal; } else { legCount = config.legs.seRegularRounds; } } else if (isFrontsideSemifinal(matchId, tournament.bracketSize)) { legCount = config.legs.frontsideSemifinal; } else { legCount = config.legs.regularRounds; } const match = { id: matchId, numericId: numericId++, round: roundInfo.round, side: 'frontside', player1: player1, player2: player2, winner: null, loser: null, lane: null, legs: legCount, referee: null, active: false, completed: false, positionInRound: matchIndex }; matches.push(match); } }); } /** * GENERATE BACKSIDE MATCHES */ function generateBacksideMatches(structure, startId) { let numericId = startId; structure.backside.forEach((roundInfo) => { for (let matchIndex = 0; matchIndex < roundInfo.matches; matchIndex++) { // All backside matches start with TBD players const player1 = createTBDPlayer(`bs-${roundInfo.round}-${matchIndex}-1`); const player2 = createTBDPlayer(`bs-${roundInfo.round}-${matchIndex}-2`); // Determine leg count based on match type let legCount; const matchId = `BS-${roundInfo.round}-${matchIndex + 1}`; if (isBacksideSemifinal(matchId, tournament.bracketSize)) { legCount = config.legs.backsideSemifinal; } else { legCount = config.legs.regularRounds; } const match = { id: matchId, numericId: numericId++, round: roundInfo.round, side: 'backside', player1: player1, player2: player2, winner: null, loser: null, lane: null, legs: legCount, referee: null, active: false, completed: false, positionInRound: matchIndex }; matches.push(match); } }); } /** * GENERATE FINAL MATCHES */ function generateFinalMatches(startId) { // Backside Final const backsideFinal = { id: 'BS-FINAL', numericId: startId, round: 'final', side: 'backside-final', player1: createTBDPlayer('bs-final-1'), player2: createTBDPlayer('bs-final-2'), winner: null, loser: null, lane: null, legs: config.legs.backsideFinal, referee: null, active: false, completed: false }; // Grand Final const grandFinal = { id: 'GRAND-FINAL', numericId: startId + 1, round: 'grand-final', side: 'grand-final', player1: createTBDPlayer('grand-final-1'), player2: createTBDPlayer('grand-final-2'), winner: null, loser: null, lane: null, legs: config.legs.grandFinal, referee: null, active: false, completed: false }; matches.push(backsideFinal); matches.push(grandFinal); } // generateBronzeMatch() removed — bronze is now a regular FS round match // generated by generateFrontsideMatches() via calculateCleanBracketStructure() /** * CREATE TBD PLAYER OBJECT */ function createTBDPlayer(id) { return { id: id, name: 'TBD' }; } /** * TOGGLE MATCH ACTIVE STATE - Simple match activation/deactivation */ function toggleActive(matchId) { const match = matches.find(m => m.id === matchId); if (!match) { console.error(`Match ${matchId} not found`); return false; } const currentState = getMatchState(match); // Can only toggle between READY and LIVE states if (currentState === 'pending') { alert('Cannot start match: Players not yet determined'); return false; } if (currentState === 'completed') { alert('Match is already completed'); return false; } // Capture state before change for transaction const wasActive = match.active; // Toggle active state match.active = !match.active; console.log(`Match ${matchId} ${match.active ? 'activated' : 'deactivated'}`); // Anchor achievement snapshot on Start Match. // Captures both players' stats at the moment the match becomes active — this is // the baseline for the achievement diff computed on Confirm Winner. Overwritten on // every Start; any pending uncommitted stats from a previous winner-confirm session // have already been rolled back (Cancel/Esc → handleCancel restorePlayerStats). if (match.active && !window.rebuildInProgress) { match.preMatchSnapshot = {}; [match.player1, match.player2].forEach(p => { if (!p || !p.id) return; const player = players.find(pl => String(pl.id) === String(p.id)); if (player) match.preMatchSnapshot[player.id] = snapshotPlayerStats(player); }); } // Create transaction for match state change if (!window.rebuildInProgress) { const transaction = { id: generateTransactionId(), type: match.active ? 'START_MATCH' : 'STOP_MATCH', description: `${matchId}: ${match.active ? 'Started' : 'Stopped'}`, timestamp: new Date().toISOString(), matchId: matchId, beforeState: { active: wasActive }, afterState: { active: match.active } }; saveTransaction(transaction); } // Save and render if (typeof saveTournament === 'function') { saveTournament(); } if (typeof renderBracket === 'function') { renderBracket(); } return true; } /** * TOGGLE ACTIVE WITH VALIDATION - Wrapper that validates referee conflicts before toggling * @param {string} matchId - The match ID to toggle * @returns {boolean} - True if successful, false if validation failed */ function toggleActiveWithValidation(matchId) { const match = matches.find(m => m.id === matchId); if (!match) { console.error(`Match ${matchId} not found`); return false; } const currentState = getMatchState(match); // Only validate when trying to START a match (transition from ready to live) // Don't validate when stopping a match if (currentState === 'ready') { // A player can't be on two boards at once. Only possible in a group stage, where every // match's players are known from the start. const playing = typeof getPlayersInLiveMatches === 'function' ? getPlayersInLiveMatches(matchId) : []; const busy = [match.player1, match.player2].filter(p => p && playing.includes(parseInt(p.id))).map(p => p.name); if (busy.length) { alert(`Cannot start match: ${busy.join(' and ')} ${busy.length > 1 ? 'are' : 'is'} playing another match`); return false; } // Groups and cups: a referee chosen before the start must be free when it starts (a referee is // only taken while their match is live, so the choice itself didn't check) if (getFormat() === 'GROUPS' && match.referee && typeof isPlayerAvailableAsReferee === 'function' && !isPlayerAvailableAsReferee(match.referee, matchId)) { const ref = players.find(p => String(p.id) === String(match.referee)); alert(`Cannot start match: the referee${ref ? `, ${ref.name},` : ''} is playing or refereeing another match. Choose another referee, or wait.`); return false; } // Check for referee conflicts using shared utility function if (typeof checkRefereeConflict === 'function') { const conflictInfo = checkRefereeConflict(matchId); if (conflictInfo.hasConflict) { const playerNames = conflictInfo.conflictedPlayers.join(' and '); alert(`Cannot start match: ${playerNames} is currently refereeing another match`); return false; } } } // A planned referee (groups and cups) is filled in as the match starts, when no one has been // chosen and they are free; otherwise the operator picks one, as always if (currentState === 'ready' && !match.referee && typeof Groups !== 'undefined') { const planned = Groups.plannedRefereeFor(match); if (planned && isPlayerAvailableAsReferee(planned.id, matchId)) updateMatchReferee(matchId, planned.id); } // If validation passes (or we're stopping a match), call base toggle function return toggleActive(matchId); } /** * SIMPLE MATCH STATE GETTER - Determines current match state */ function getMatchState(match) { if (!match) return 'pending'; if (match.completed) return 'completed'; if (match.active) return 'live'; // Check if both players are ready (not TBD or walkover) if (canMatchStart(match)) return 'ready'; return 'pending'; } /** * CHECK IF MATCH CAN START - Both players must be real */ function canMatchStart(match) { if (!match || !match.player1 || !match.player2) return false; const player1Valid = match.player1.name !== 'TBD' && !match.player1.isBye; const player2Valid = match.player2.name !== 'TBD' && !match.player2.isBye; return player1Valid && player2Valid; } /** * UPDATE MATCH LANE - Simple lane assignment */ function updateMatchLane(matchId, newLane) { const match = matches.find(m => m.id === matchId); if (!match) { console.error(`Match ${matchId} not found`); return false; } // Capture state before change for transaction const oldLane = match.lane; const parsedNewLane = newLane ? parseInt(newLane) : null; match.lane = parsedNewLane; console.log(`Lane updated for ${matchId}: ${match.lane || 'none'}`); // Create transaction for lane assignment if (!window.rebuildInProgress) { const transaction = { id: generateTransactionId(), type: 'ASSIGN_LANE', description: `${matchId}: Lane ${parsedNewLane ? `assigned to ${parsedNewLane}` : 'cleared'}`, timestamp: new Date().toISOString(), matchId: matchId, afterState: { lane: parsedNewLane } // Keep for potential future analytics/audit trail // beforeState removed - never used by undo system }; saveTransaction(transaction); } if (typeof saveTournament === 'function') { saveTournament(); } // Refresh all lane dropdowns to update conflict detection if (typeof refreshAllLaneDropdowns === 'function') { refreshAllLaneDropdowns(); } // Redraw the bracket so the match's lane label (L1, L2 …) shows the new lane if (typeof renderBracket === 'function') { renderBracket(); } // Refresh Match Controls if it's open const modal = document.getElementById('matchCommandCenterModal'); if (modal && (modal.style.display === 'flex' || modal.style.display === 'block') && typeof showMatchCommandCenter === 'function') { setTimeout(() => { showMatchCommandCenter(); }, 200); } return true; } /** * DEBUG: Show bracket generation results */ function debugBracketGeneration() { if (!tournament || !tournament.bracket) { console.log('No bracket generated yet'); return; } console.log('=== BRACKET GENERATION DEBUG ==='); console.log(`Bracket size: ${tournament.bracketSize}`); console.log(`Total matches: ${matches.length}`); // Show first round matches const firstRound = matches.filter(m => m.side === 'frontside' && m.round === 1); console.log(`First round matches: ${firstRound.length}`); firstRound.forEach(match => { const p1 = match.player1?.name || 'Empty'; const p2 = match.player2?.name || 'Empty'; const p1Type = isWalkover(match.player1) ? 'WALKOVER' : 'REAL'; const p2Type = isWalkover(match.player2) ? 'WALKOVER' : 'REAL'; console.log(`${match.id}: ${p1} (${p1Type}) vs ${p2} (${p2Type})`); }); // Check for auto-advancement opportunities const autoAdvanceMatches = matches.filter(shouldAutoAdvance); console.log(`Matches ready for auto-advancement: ${autoAdvanceMatches.length}`); } /** * Enhanced showWinnerConfirmation with proper leg score validation * Replaces the existing function in clean-match-progression.js */ /** * Build the match-progression block shown in the winner confirmation body. * Safe — all player names and destinations go in via textContent. */ function _buildWinnerProgressionBlock(matchId, winner, loser, progression) { const block = document.createElement('div'); block.className = 'winner-progression'; const title = document.createElement('div'); title.className = 'winner-progression__title'; title.textContent = 'Match Progression'; block.appendChild(title); // Winner line const winnerLine = document.createElement('div'); winnerLine.className = 'winner-progression__winner'; const wName = document.createElement('strong'); wName.textContent = winner.name; winnerLine.appendChild(wName); // groups and cups: each cup's bronze and final decide places (getPlayerProgressionForDisplay) const cupEnd = typeof getPlayerProgressionForDisplay === 'function' && ((getFormat() === 'GROUPS' && /^[AB]-[FB]$/.test(matchId)) || (typeof Plate !== 'undefined' && Plate.on() && (matchId === Plate.finalId() || matchId === Plate.bronzeId())) || (getFormat() === 'SWISS' && /^K-[FB]$/.test(matchId))); if (cupEnd) { winnerLine.appendChild(document.createTextNode(' ' + getPlayerProgressionForDisplay(winner.id, matchId, true))); } else if (progression.winner) { winnerLine.appendChild(document.createTextNode(' advances to ')); const dest = document.createElement('strong'); dest.textContent = progression.winner[0]; winnerLine.appendChild(dest); } else { winnerLine.appendChild(document.createTextNode(' wins the tournament!')); } block.appendChild(winnerLine); // Loser line const loserLine = document.createElement('div'); loserLine.className = 'winner-progression__loser'; const lName = document.createElement('strong'); lName.textContent = loser.name; loserLine.appendChild(lName); if (cupEnd) { loserLine.appendChild(document.createTextNode(' ' + getPlayerProgressionForDisplay(loser.id, matchId, false))); } else if (progression.loser) { loserLine.appendChild(document.createTextNode(' moves to ')); const dest = document.createElement('strong'); dest.textContent = progression.loser[0]; loserLine.appendChild(dest); } else if (typeof Qualifiers !== 'undefined' && Qualifiers.isQualifierId(matchId)) { loserLine.appendChild(document.createTextNode(' is not qualified (')); const rankText = document.createElement('strong'); rankText.textContent = formatRanking(33); loserLine.appendChild(rankText); loserLine.appendChild(document.createTextNode(')')); } else { const rank = typeof getEliminationRankForMatch === 'function' ? getEliminationRankForMatch(matchId, tournament.bracketSize) : null; if (rank && typeof formatRanking === 'function') { loserLine.appendChild(document.createTextNode(' is placed ')); const rankText = document.createElement('strong'); rankText.textContent = formatRanking(rank); loserLine.appendChild(rankText); } else { loserLine.appendChild(document.createTextNode(' is eliminated')); } } block.appendChild(loserLine); // a qualifier only decides who gets to play (Docs/QUALIFIERS.md) if (typeof Qualifiers !== 'undefined' && Qualifiers.isQualifierId(matchId)) { const note = document.createElement('div'); note.className = 'winner-progression__note'; note.textContent = 'A qualifier: only the score is kept, to decide who goes through. No achievements, and no matches or legs in the statistics.'; block.appendChild(note); } return block; } /** * The Confirm Winner dialog: winner, leg score and the achievements entered while it is open. * Also the Edit match dialog (bracket-rendering.js handleMatchEdit), when `edit` is given. * @param {string} matchId * @param {object} winner * @param {object} loser * @param {function(number, number, object|null, object): (boolean|void)} onConfirm - winner legs, * loser legs, achievements delta per player id, and the winner (the other player if changed) * @param {object} [edit] - Edit match: the recorded result to start from * @param {object} edit.baseline - stats snapshots the achievements diff is taken against * @param {object} edit.restore - stats snapshots Cancel restores to * @param {boolean} edit.canChangeWinner - offer "Change winner" * @param {string|null} edit.lockedReason - why the winner can't change * @param {number} [edit.winnerLegs] - recorded legs, pre-filled * @param {number} [edit.loserLegs] * @returns {boolean} false when the dialog's elements are missing */ function showWinnerConfirmation(matchId, winner, loser, onConfirm, edit) { const modal = document.getElementById('winnerConfirmModal'); const body = document.getElementById('winnerConfirmBody'); const cancelBtn = document.getElementById('winnerConfirmCancel'); const confirmBtn = document.getElementById('winnerConfirmOK'); if (!modal || !body || !cancelBtn || !confirmBtn) { console.error('Winner confirmation modal elements not found'); return false; } // Sidebar — match metadata + Edit Statistics links (safe via textContent) document.getElementById('winnerSidebarMatch').textContent = matchId; document.getElementById('winnerSidebarBracket').textContent = _bracketLabel(matchId); const winnerLink = document.getElementById('winnerStatsLink'); winnerLink.onclick = () => openStatsModalFromConfirmation(winner.id, matchId); // reads `winner` when clicked const loserLink = document.getElementById('loserStatsLink'); loserLink.onclick = () => openStatsModalFromConfirmation(loser.id, matchId); // a qualifier: no statistics to edit (nothing in a qualifier counts) const isQualifier = typeof Qualifiers !== 'undefined' && Qualifiers.isQualifierId(matchId); const statsStack = winnerLink.parentElement; statsStack.style.display = isQualifier ? 'none' : ''; if (statsStack.previousElementSibling) statsStack.previousElementSibling.style.display = isQualifier ? 'none' : ''; // Everything that names the winner and the loser; again when Edit match changes the winner const paintPlayers = () => { winnerLink.textContent = winner.name; loserLink.textContent = loser.name; // Title — "{winner} beats {loser}" document.getElementById('winnerConfirmDialogTitle').textContent = `${edit ? 'Edit: ' : ''}${winner.name} beats ${loser.name}`; // Body — Edit match's winner line, then the progression block (declaration and hint live in title / sidebar now) body.replaceChildren(); if (edit) { const winnerEdit = document.createElement('div'); winnerEdit.className = 'winner-progression'; if (edit.canChangeWinner) { const change = document.createElement('button'); change.type = 'button'; change.className = 'btn'; change.textContent = 'Change winner'; change.addEventListener('click', () => { [winner, loser] = [loser, winner]; paintPlayers(); }); winnerEdit.appendChild(change); } else { const note = document.createElement('div'); note.className = 'winner-progression__note'; note.textContent = `The winner can't be changed: ${edit.lockedReason || 'the matches it feeds are played'}. To change it, undo the later results first. The score and achievements can still be edited.`; winnerEdit.appendChild(note); } body.appendChild(winnerEdit); } const progressionTable = getProgressionTable(); const progression = progressionTable && progressionTable[matchId]; if (progression) { body.appendChild(_buildWinnerProgressionBlock(matchId, winner, loser, progression)); } else if (getFormat() === 'SWISS' && typeof Swiss !== 'undefined' && Swiss.isRoundId(matchId)) { // a Swiss round match moves no one on: the result goes into the table const block = document.createElement('div'); block.className = 'winner-progression'; const title = document.createElement('div'); title.className = 'winner-progression__title'; title.textContent = Swiss.roundName(matchId); const line = document.createElement('div'); line.textContent = 'The result goes into the table; the legs count for the table too. The last result of a round draws the next round.'; block.append(title, line); body.appendChild(block); } else if (getFormat() === 'GROUPS' && typeof Groups !== 'undefined' && Groups.isGroupId(matchId)) { // a group match moves no one on: the result goes into the group table const block = document.createElement('div'); block.className = 'winner-progression'; const title = document.createElement('div'); title.className = 'winner-progression__title'; title.textContent = Groups.roundName(matchId); const line = document.createElement('div'); line.textContent = 'The result goes into the group table; the legs count for the table too.'; block.append(title, line); body.appendChild(block); } if (winnerNameSpan) winnerNameSpan.textContent = winner.name; if (loserNameSpan) loserNameSpan.textContent = loser.name; }; // Populate leg score fields const match = matches.find(m => m.id === matchId); const winnerNameSpan = document.getElementById('winnerNameForLegs'); const loserNameSpan = document.getElementById('loserNameForLegs'); const winnerLegsInput = document.getElementById('winnerLegs'); const loserLegsInput = document.getElementById('loserLegs'); const validationMessage = document.getElementById('legValidationMessage'); paintPlayers(); // Pre-fill winner legs based on match format (Edit match: the recorded score) if (winnerLegsInput && match) { const matchLegs = match.legs || 3; const minToWin = Math.ceil(matchLegs / 2); winnerLegsInput.value = edit && Number.isFinite(edit.winnerLegs) ? edit.winnerLegs : minToWin; winnerLegsInput.max = matchLegs; } // Set up loser legs if (loserLegsInput) { loserLegsInput.value = edit && Number.isFinite(edit.loserLegs) ? edit.loserLegs : 0; if (match) { loserLegsInput.max = Math.max(0, match.legs - 1); // Max legs loser can have (0 for Bo1) } } confirmBtn.textContent = edit ? 'Save changes' : 'Confirm Winner'; // Clear any existing validation message if (validationMessage) { validationMessage.style.display = 'none'; } // Set match ID on scan button so openResultQRScanner() knows which match to validate against. // Hidden unless the Chalker handover is set to QR (see getChalkerHandover()). const scanBtn = document.getElementById('scanResultQRBtn'); if (scanBtn) { scanBtn.dataset.matchId = matchId; const _qrMode = (typeof getChalkerHandover !== 'function') || getChalkerHandover() === 'qr'; scanBtn.style.display = _qrMode && !edit ? '' : 'none'; // an edit is by hand } // Use the match-anchored snapshot recorded at Start Match (toggleActive). // This is the reliable baseline for the achievement diff — it can't drift like the // old "snapshot at dialog open" approach did. // // Fallback: if the snapshot is missing (match started before this feature, or some // unusual code path), take one now. The diff then only captures stats added from this // point forward — same behavior as the old approach, so the dialog still works. const matchObj = matches.find(m => m.id === matchId); if (!edit && matchObj && !matchObj.preMatchSnapshot) { matchObj.preMatchSnapshot = { [winner.id]: snapshotPlayerStats(winner), [loser.id]: snapshotPlayerStats(loser) }; } // _completionSnapshot is now a pointer into the match-anchored snapshot. // renderCompletionAchievements, handleConfirm, and handleCancel all read through it // unchanged. // Edit match: the baseline is the stats without this match's recorded achievements instead _completionSnapshot = edit ? edit.baseline : (matchObj ? matchObj.preMatchSnapshot : null); _completionRestore = edit ? edit.restore : null; // Use dialog stack to show modal. // enableEsc=false because handleKeyPress (above) handles Esc itself — routing it through // handleCancel so stats added in this session are rolled back. If we let the stack's // own Esc handler fire, the dialog would just pop without cleanup, leaving the snapshot // stale for the next open. pushDialog('winnerConfirmModal', () => { const modal = document.getElementById('winnerConfirmModal'); if (modal) modal.style.display = 'block'; }, false); // Hide any stale achievements box from a previous open of this dialog const summaryBox = document.getElementById('completionAchievementsSummary'); if (summaryBox) summaryBox.style.display = 'none'; if (edit) renderCompletionAchievements(); // what the match recorded // Focus cancel button by default setTimeout(() => { cancelBtn.focus(); cancelBtn.style.boxShadow = '0 0 0 3px rgba(108, 117, 125, 0.3)'; cancelBtn.style.transform = 'scale(1.05)'; }, 100); // Add real-time validation to input fields const validateInputs = () => { const winnerLegs = parseInt(winnerLegsInput?.value) || 0; const loserLegs = parseInt(loserLegsInput?.value) || 0; const matchLegs = match?.legs || 3; return validateLegScores(winnerLegs, loserLegs, matchLegs); }; // Add input event listeners for real-time validation. // Named handler stored on the modal (like _cancelHandler) so it can be removed // in cleanup and on re-open — anonymous handlers accumulated across opens. const handleLegsInput = () => { const validation = validateInputs(); updateValidationDisplay(validation); confirmBtn.disabled = !validation.valid; }; if (modal._legsInputHandler) { if (winnerLegsInput) winnerLegsInput.removeEventListener('input', modal._legsInputHandler); if (loserLegsInput) loserLegsInput.removeEventListener('input', modal._legsInputHandler); } modal._legsInputHandler = handleLegsInput; if (winnerLegsInput) { winnerLegsInput.addEventListener('input', handleLegsInput); } if (loserLegsInput) { loserLegsInput.addEventListener('input', handleLegsInput); } // Handle button clicks const handleCancel = () => { // Restore stats to the match's anchored baseline (Start Match snapshot). // The snapshot lives on match.preMatchSnapshot and stays there — it'll be the // baseline for the next Confirm Winner attempt on this same match. The global // _completionSnapshot is just a pointer; don't null it out. const restoreFrom = _completionRestore || _completionSnapshot; // Edit match restores to how the dialog opened if (restoreFrom) { [winner, loser].forEach(p => { const snap = restoreFrom[p.id]; if (!snap) return; const player = players.find(pl => String(pl.id) === String(p.id)); if (player) restorePlayerStats(player, snap); }); saveTournament(); } console.log(`Winner selection cancelled for match ${matchId}`); cleanup(); popDialog(); // Use dialog stack to close and restore parent }; const handleConfirm = () => { const winnerLegs = parseInt(winnerLegsInput?.value) || 0; const loserLegs = parseInt(loserLegsInput?.value) || 0; const matchLegs = match?.legs || 3; // Final validation before confirming const validation = validateLegScores(winnerLegs, loserLegs, matchLegs); if (!validation.valid) { showValidationError(validation.error); return; } // Compute achievement delta from the match-anchored snapshot. // The snapshot stays on match.preMatchSnapshot as audit data — useful if anyone // inspects the match record later. The global _completionSnapshot pointer is // harmless to leave dangling (it'll be reassigned on the next dialog open). let achievements = null; if (_completionSnapshot) { achievements = {}; [winner, loser].forEach(p => { const snap = _completionSnapshot[p.id]; const player = players.find(pl => String(pl.id) === String(p.id)); achievements[p.id] = (snap && player) ? diffPlayerStats(snap, snapshotPlayerStats(player)) : null; }); } console.log(`Winner confirmed for match ${matchId}: ${winner.name} (${winnerLegs}-${loserLegs})`); if (isQualifier) achievements = null; // nothing in a qualifier counts onConfirm(winnerLegs, loserLegs, achievements, winner); cleanup(); popDialog(); // Use dialog stack to close and restore parent }; const cleanup = () => { // Reset button styles and label cancelBtn.style.boxShadow = ''; cancelBtn.style.transform = ''; cancelBtn.textContent = 'Cancel'; confirmBtn.textContent = 'Confirm Winner'; _completionRestore = null; // Clear achievements summary const summaryBox = document.getElementById('completionAchievementsSummary'); if (summaryBox) summaryBox.style.display = 'none'; // Remove event listeners cancelBtn.removeEventListener('click', handleCancel); confirmBtn.removeEventListener('click', handleConfirm); document.removeEventListener('keydown', handleKeyPress); // Remove input listeners if (winnerLegsInput) { winnerLegsInput.removeEventListener('input', handleLegsInput); } if (loserLegsInput) { loserLegsInput.removeEventListener('input', handleLegsInput); } modal._legsInputHandler = null; // Clear validation message if (validationMessage) { validationMessage.style.display = 'none'; } // Re-enable confirm button confirmBtn.disabled = false; }; const handleKeyPress = (e) => { if (e.key !== 'Enter' && e.key !== 'Escape') return; // Defer when another dialog is on top of us (e.g. statsModal opened via Edit Statistics). const top = window.dialogStack && window.dialogStack[window.dialogStack.length - 1]; if (!top || top.id !== 'winnerConfirmModal') return; if (e.key === 'Enter') { const validation = validateInputs(); if (validation.valid) handleConfirm(); } else { // Esc routes through handleCancel so stats added in this session are rolled back. // Without this, the snapshot stays stale and the next open's diff is computed // against the wrong baseline. e.preventDefault(); handleCancel(); } }; // Remove any existing event listeners first (in case this is a restoration) if (modal._cancelHandler) { cancelBtn.removeEventListener('click', modal._cancelHandler); } if (modal._confirmHandler) { confirmBtn.removeEventListener('click', modal._confirmHandler); } if (modal._keyHandler) { document.removeEventListener('keydown', modal._keyHandler); } // Store handlers on modal for cleanup modal._cancelHandler = handleCancel; modal._confirmHandler = handleConfirm; modal._keyHandler = handleKeyPress; // Add event listeners cancelBtn.addEventListener('click', handleCancel); confirmBtn.addEventListener('click', handleConfirm); document.addEventListener('keydown', handleKeyPress); // Initial validation const initialValidation = validateInputs(); updateValidationDisplay(initialValidation); confirmBtn.disabled = !initialValidation.valid; return true; } /** * Validate leg scores with comprehensive rules */ function validateLegScores(winnerLegs, loserLegs, matchLegs) { // Basic number validation if (isNaN(winnerLegs) || isNaN(loserLegs)) { return { valid: false, error: 'Please enter valid numbers for both leg counts' }; } if (winnerLegs < 0 || loserLegs < 0) { return { valid: false, error: 'Leg counts cannot be negative' }; } // Winner must have more legs than loser (core requirement) if (winnerLegs <= loserLegs) { return { valid: false, error: 'Winner must have more legs than loser' }; } // Optional: Check if it makes sense for the match format const minToWin = Math.ceil(matchLegs / 2); if (winnerLegs < minToWin) { return { valid: false, error: `Winner needs at least ${minToWin} legs to win a best-of-${matchLegs} match` }; } // Optional: Check if total legs is reasonable (not enforced strictly) const totalLegs = winnerLegs + loserLegs; if (totalLegs > matchLegs + 2) { return { valid: false, error: `Total legs (${totalLegs}) seems high for a best-of-${matchLegs} match. Maximum expected: ${matchLegs + 2}` }; } // Check if loser has too many legs (can't exceed what's possible) const maxLoserLegs = matchLegs - 1; // In Bo5, max loser can have is 4 legs if (loserLegs > maxLoserLegs) { return { valid: false, error: `Loser cannot have more than ${maxLoserLegs} legs in a best-of-${matchLegs} match` }; } return { valid: true, error: null }; } /** * Update validation display in the modal */ function updateValidationDisplay(validation) { let validationMessage = document.getElementById('legValidationMessage'); // Create validation message element if it doesn't exist if (!validationMessage) { const legScoresSection = document.getElementById('legScoresSection'); if (legScoresSection) { validationMessage = document.createElement('div'); validationMessage.id = 'legValidationMessage'; validationMessage.style.marginTop = '10px'; validationMessage.style.padding = '8px 12px'; validationMessage.style.borderRadius = '4px'; validationMessage.style.fontSize = '14px'; validationMessage.style.fontWeight = '500'; legScoresSection.appendChild(validationMessage); } } if (validationMessage) { if (validation.valid) { validationMessage.style.display = 'none'; } else { validationMessage.style.display = 'block'; validationMessage.style.background = '#fff5f5'; validationMessage.style.color = '#dc2626'; validationMessage.style.border = '1px solid #fecaca'; validationMessage.textContent = validation.error; } } } /** * Show validation error as alert (fallback) */ function showValidationError(error) { alert('❌ Invalid leg scores:\n\n' + error); } // Make functions globally available if (typeof window !== 'undefined') { window.showWinnerConfirmation = showWinnerConfirmation; window.validateLegScores = validateLegScores; window.updateValidationDisplay = updateValidationDisplay; window.showValidationError = showValidationError; } // TOURNAMENT HISTORY MANAGEMENT (TRANSACTIONAL) const MAX_HISTORY_ENTRIES = 1000; // Keep last 1000 transactions (covers extensive 32-player tournaments with full operational history) /** * NEW: Save a single transaction to the history log. * @param {object} transaction The transaction object to save. */ function saveTransaction(transaction) { if (!tournament || !tournament.id) { console.warn('No active tournament - transaction not saved'); return; } const historyKey = `tournament_${tournament.id}_history`; let history = getTournamentHistory(); history.unshift(transaction); // Add to the beginning if (history.length > MAX_HISTORY_ENTRIES) { history = history.slice(0, MAX_HISTORY_ENTRIES); } localStorage.setItem(historyKey, JSON.stringify(history)); } /** * Get tournament history from localStorage. * Per-tournament isolation - reads from tournament-specific key. */ function getTournamentHistory() { if (!tournament || !tournament.id) { return []; } try { const historyKey = `tournament_${tournament.id}_history`; const historyData = localStorage.getItem(historyKey); if (!historyData || historyData === 'undefined') { return []; } const history = JSON.parse(historyData); return history; } catch (error) { console.error('Error loading tournament history:', error); return []; } } /** * Clear tournament history. * Per-tournament isolation - clears only current tournament's history. */ function clearTournamentHistory() { if (!tournament || !tournament.id) { console.warn('No active tournament - cannot clear history'); return; } const historyKey = `tournament_${tournament.id}_history`; localStorage.removeItem(historyKey); console.log(`✓ Tournament history cleared for ${tournament.name}`); } /** * Debug function to show current history */ function debugHistory() { const history = getTournamentHistory(); console.log('=== TOURNAMENT HISTORY ==='); console.log(`Total entries: ${history.length}`); history.forEach((entry, index) => { const time = new Date(entry.timestamp).toLocaleTimeString(); console.log(`${index + 1}. [${time}] ${entry.description}`); }); if (history.length === 0) { console.log('No history entries found'); } } /** * Open stats modal from winner confirmation dialog using dialog stack */ function openStatsModalFromConfirmation(playerId, matchId) { // Save current leg score values const winnerLegsInput = document.getElementById('winnerLegs'); const loserLegsInput = document.getElementById('loserLegs'); const savedWinnerLegs = winnerLegsInput ? winnerLegsInput.value : ''; const savedLoserLegs = loserLegsInput ? loserLegsInput.value : ''; // Open stats modal - dialog stack will handle hiding winner modal openStatsModal(playerId); // Override closeStatsModal to restore input values and update Cancel label after returning const originalClose = window.closeStatsModal; window.closeStatsModal = function() { // Call original close (which calls popDialog) if (originalClose) { originalClose(); } // Restore input values and check for achievement changes after dialog stack restores winner modal setTimeout(() => { const winnerLegsInputRestore = document.getElementById('winnerLegs'); const loserLegsInputRestore = document.getElementById('loserLegs'); if (winnerLegsInputRestore && savedWinnerLegs !== '') { winnerLegsInputRestore.value = savedWinnerLegs; } if (loserLegsInputRestore && savedLoserLegs !== '') { loserLegsInputRestore.value = savedLoserLegs; } // Update the achievements summary and Cancel button label if (_completionSnapshot) { renderCompletionAchievements(); // Edit match: changes against how the dialog opened, not against the baseline const anyChanges = Object.entries(_completionRestore || _completionSnapshot).some(([pid, snap]) => { const player = players.find(p => String(p.id) === String(pid)); return player && diffPlayerStats(snap, snapshotPlayerStats(player)) !== null; }); const cancelBtn = document.getElementById('winnerConfirmCancel'); if (cancelBtn) { cancelBtn.textContent = anyChanges ? 'Cancel & revert achievements' : 'Cancel'; } } }, 0); // Restore original close function window.closeStatsModal = originalClose; }; } // NEW: Enhanced match state detection with help suggestions function detectMatchIssues() { if (!matches || matches.length === 0) return; const readyMatches = matches.filter(m => getMatchState && getMatchState(m) === 'ready').length; const liveMatches = matches.filter(m => getMatchState && getMatchState(m) === 'live').length; // Help suggestions based on match states /* if (readyMatches > 0 && liveMatches === 0 && typeof showHelpHint === 'function') { setTimeout(() => { showHelpHint(`${readyMatches} match${readyMatches > 1 ? 'es' : ''} ready to start. Click "Start" to begin.`); }, 2000); } */ if (liveMatches > 3 && typeof showHelpHint === 'function') { setTimeout(() => { showHelpHint('Many matches are live. Consider using lanes to organize dartboards.'); }, 1000); } } // NEW: Help system integration for common user actions function onPageChange(newPageId) { // Trigger contextual help suggestions when switching pages if (typeof triggerContextualHelp === 'function') { setTimeout(() => { triggerContextualHelp(); }, 1000); } // Page-specific help triggers if (newPageId === 'tournament' && tournament && tournament.bracket) { setTimeout(() => { detectMatchIssues(); }, 1500); } } // NEW: Validation helper for leg scores in winner confirmation function validateAndShowWinnerDialog(matchId, playerNumber) { const match = matches.find(m => m.id === matchId); if (!match) { console.error(`Match ${matchId} not found`); return false; } // Can only select winner if match is active/live if (!match.active) { alert('Match must be active to select winner'); // HELP SYSTEM INTEGRATION if (typeof showHelpHint === 'function') { showHelpHint('Click "Start" button first to activate the match before selecting winner.'); } return false; } const winner = playerNumber === 1 ? match.player1 : match.player2; const loser = playerNumber === 1 ? match.player2 : match.player1; // Cannot select walkover or TBD as winner if (isWalkover(winner) || winner.name === 'TBD') { alert('Cannot select walkover or TBD as winner'); return false; } // Show enhanced confirmation dialog with validation if (config.ui && config.ui.confirmWinnerSelection) { return showWinnerConfirmation(matchId, winner, loser, (winnerLegs, loserLegs, achievements) => { // This callback runs if user confirms with validated leg scores const success = completeMatch(matchId, playerNumber, winnerLegs, loserLegs, 'MANUAL', achievements); if (success) { // Re-render bracket if (typeof renderBracket === 'function') { renderBracket(); } // Refresh lane dropdowns if available if (typeof refreshAllLaneDropdowns === 'function') { setTimeout(refreshAllLaneDropdowns, 100); } } return success; }); } // If no confirmation needed, complete match normally with no leg scores const success = completeMatch(matchId, playerNumber, 0, 0); if (success) { // Re-render bracket if (typeof renderBracket === 'function') { renderBracket(); } // Refresh lane dropdowns if available if (typeof refreshAllLaneDropdowns === 'function') { setTimeout(refreshAllLaneDropdowns, 100); } // Refresh Match Controls if it's open to show updated match state const modal = document.getElementById('matchCommandCenterModal'); if (modal && (modal.style.display === 'flex' || modal.style.display === 'block') && typeof showMatchCommandCenter === 'function') { setTimeout(() => { showMatchCommandCenter(); }, 100); } } return success; } /** * Check if a match is a frontside semifinal */ function isFrontsideSemifinal(matchId, bracketSize) { const frontsideSemifinals = { 8: 'FS-3-1', 16: 'FS-4-1', 32: 'FS-5-1' }; return frontsideSemifinals[bracketSize] === matchId; } /** * Check if a match is a backside semifinal */ function isBacksideSemifinal(matchId, bracketSize) { const backsideSemifinals = { 8: 'BS-3-1', 16: 'BS-5-1', 32: 'BS-7-1' }; return backsideSemifinals[bracketSize] === matchId; } /** * Check if a match is the SE bronze match (penultimate round, 1 match). * Bronze match IDs by bracket size: 4→FS-2-1, 8→FS-3-1, 16→FS-4-1, 32→FS-5-1 * * @param {string} matchId - The match ID to check * @param {number} bracketSize - The bracket size * @returns {boolean} True if this is the SE bronze match */ function isSEBronzeMatch(matchId, bracketSize) { const seBronzeMatches = { 4: 'FS-2-1', 8: 'FS-3-1', 16: 'FS-4-1', 32: 'FS-5-1' }; return seBronzeMatches[bracketSize] === matchId; } /** * Check if a match is the SE final match (last round, 1 match). * Final match IDs by bracket size: 4→FS-3-1, 8→FS-4-1, 16→FS-5-1, 32→FS-6-1 * * @param {string} matchId - The match ID to check * @param {number} bracketSize - The bracket size * @returns {boolean} True if this is the SE final match */ function isSEFinalMatch(matchId, bracketSize) { const seFinalMatches = { 4: 'FS-3-1', 8: 'FS-4-1', 16: 'FS-5-1', 32: 'FS-6-1' }; return seFinalMatches[bracketSize] === matchId; } /** * Check if a match is an SE semifinal match (round total - 2). * Semifinal round by bracket size: 4→R1, 8→R2, 16→R3, 32→R4 * * @param {string} matchId - The match ID to check * @param {number} bracketSize - The bracket size * @returns {boolean} True if this is an SE semifinal match */ function isSESemifinal(matchId, bracketSize) { const seSemifinalRounds = { 4: 1, 8: 2, 16: 3, 32: 4 }; const sfRound = seSemifinalRounds[bracketSize]; if (!sfRound) return false; const m = matchId.match(/^FS-(\d+)-/); return m && parseInt(m[1]) === sfRound; } /** * Check if a match is an SE quarterfinal match (round total - 3). * Quarterfinal round by bracket size: 8→R1, 16→R2, 32→R3 (not applicable for 4-player) * * @param {string} matchId - The match ID to check * @param {number} bracketSize - The bracket size * @returns {boolean} True if this is an SE quarterfinal match */ function isSEQuarterfinal(matchId, bracketSize) { const seQuarterfinalRounds = { 8: 1, 16: 2, 32: 3 }; const qfRound = seQuarterfinalRounds[bracketSize]; if (!qfRound) return false; const m = matchId.match(/^FS-(\d+)-/); return m && parseInt(m[1]) === qfRound; } /** * Returns the display name for a given SE round number. * Named from the end backwards: Final, Bronze Final, Semifinals, Quarterfinals, Round N. * Single source of truth — used by both Match Controls and bracket rendering. * * @param {number} round - The round number (1-based) * @param {number} bracketSize - The bracket size (4, 8, 16, 32) * @returns {string} Display name for the round */ function getSERoundDisplayName(round, bracketSize) { const totalRounds = { 4: 3, 8: 4, 16: 5, 32: 6 }; const total = totalRounds[bracketSize]; if (!total) return `Round ${round}`; if (round === total) return 'Final'; if (round === total - 1) return 'Bronze'; if (round === total - 2) return 'Semifinal'; if (round === total - 3) return 'Quarterfinal'; return `Round ${round}`; } /** * Show tournament in progress warning modal * Replaces browser alert with user-friendly modal dialog */ function showTournamentProgressWarning() { // Populate sidebar with current tournament state const completedMatches = matches.filter(m => m.completed).length; const totalMatches = matches.length; document.getElementById('progressTournamentName').textContent = (tournament && tournament.name) || '-'; document.getElementById('progressTournamentStatus').textContent = tournamentStatusLabel(tournament); document.getElementById('progressMatchProgress').textContent = `${completedMatches} of ${totalMatches}`; document.getElementById('progressPlayerCount').textContent = players.length; // Show modal with Esc support pushDialog('tournamentProgressModal', null, true); } // Make functions globally available if (typeof window !== 'undefined') { // Transactional History System window.saveTransaction = saveTransaction; window.generateTransactionId = generateTransactionId; window.getTournamentHistory = getTournamentHistory; window.clearTournamentHistory = clearTournamentHistory; window.debugHistory = debugHistory; // Original Functions (unchanged) window.advancePlayer = advancePlayer; window.completeMatch = completeMatch; window.selectWinnerClean = selectWinnerClean; window.processAutoAdvancements = processAutoAdvancements; window.debugProgression = debugProgression; window.generateCleanBracket = generateCleanBracket; window.debugBracketGeneration = debugBracketGeneration; window.toggleActive = toggleActive; window.toggleActiveWithValidation = toggleActiveWithValidation; window.getMatchState = getMatchState; window.updateMatchLane = updateMatchLane; window.DE_MATCH_PROGRESSION = DE_MATCH_PROGRESSION; window.SE_MATCH_PROGRESSION = SE_MATCH_PROGRESSION; window.calculateBracketSize = calculateBracketSize; window.getProgressionTable = getProgressionTable; window.selectWinner = selectWinnerClean; window.selectWinnerV2 = selectWinnerClean; window.selectWinnerWithValidation = selectWinnerClean; window.selectWinnerWithAutoAdvancement = selectWinnerClean; window.generateBracket = generateCleanBracket; window.confirmBracketGeneration = confirmBracketGeneration; window.showWinnerConfirmation = showWinnerConfirmation; window.validateLegScores = validateLegScores; window.updateValidationDisplay = updateValidationDisplay; window.showValidationError = showValidationError; window.openStatsModalFromConfirmation = openStatsModalFromConfirmation; window.detectMatchIssues = detectMatchIssues; window.onPageChange = onPageChange; window.isFrontsideSemifinal = isFrontsideSemifinal; window.isBacksideSemifinal = isBacksideSemifinal; window.isSEBronzeMatch = isSEBronzeMatch; window.isSEFinalMatch = isSEFinalMatch; window.isSESemifinal = isSESemifinal; window.isSEQuarterfinal = isSEQuarterfinal; window.getSERoundDisplayName = getSERoundDisplayName; window.calculateAllRankings = calculateAllRankings; window.calculate8PlayerRankings = calculate8PlayerRankings; window.isWalkover = isWalkover; window.showTournamentProgressWarning = showTournamentProgressWarning; console.log('✅ Clean match progression system loaded - old system disabled'); }