// bracket-rendering.js - Bracket page entry points, match state helpers, lanes/referees, undo,
// and Match Controls. The bracket itself is drawn by BracketView (js/bracket-view.js).
// Cross-validation helper
const _0x7a = [78,101,119,84,111,110];
const _0x9b = [32,68,67,32,84,111,117,114,110,97,109,101,110,116,32,77,97,110,97,103,101,114];
/**
* Draw the bracket for the current tournament (both formats, via BracketView), refresh the
* bracket page header, and check the application signature. Called after every change that
* affects the bracket.
* @returns {void}
*/
function renderBracket() {
const canvas = document.getElementById('bracketCanvas');
if (!canvas) return;
BracketView.updateHeader();
if (!tournament || !tournament.bracket || !BracketView.isActive()) {
BracketView.deactivate();
document.getElementById('bracketMatches').innerHTML = '
No bracket generated yet
';
return;
}
clearBracket();
BracketView.render();
// Verify application identity integrity
setTimeout(() => {
const w2 = document.getElementById('tournament-watermark');
const _check = String.fromCharCode(..._0x7a, ..._0x9b);
if (w2 && w2.textContent !== _check) {
w2.textContent = _check;
console.log('Application configuration restored');
}
}, 300);
}
function clearBracket() {
const matchesContainer = document.getElementById('bracketMatches');
const linesContainer = document.getElementById('bracketLines');
if (matchesContainer) {
matchesContainer.innerHTML = '';
}
if (linesContainer) {
linesContainer.innerHTML = '';
}
// Clear progression lines directly added to bracketCanvas (but preserve structure)
const bracketCanvas = document.getElementById('bracketCanvas');
if (bracketCanvas) {
// Remove only direct children that are not the essential containers
const childNodes = Array.from(bracketCanvas.children);
childNodes.forEach(child => {
if (child.id !== 'bracketMatches' && child.id !== 'bracketLines') {
child.remove();
}
});
}
}
/**
* Returns a numeric round order for a match ID so later rounds have higher values.
* Used to determine which is a player's final (elimination) match.
* @param {string} matchId
* @returns {number}
*/
function getMatchRoundOrder(matchId) {
// Swiss: the rounds in order, then the top four
if (typeof Swiss !== 'undefined' && Swiss.isRoundId(matchId)) return parseInt(String(matchId).slice(1)) || 1;
if (typeof Swiss !== 'undefined' && Swiss.isKnockoutId(matchId)) { const r = String(matchId).slice(2); return 100 + (r === 'F' ? 3 : r === 'B' ? 2 : 1); }
// Cup and Plate: the Plate comes after the Cup's round 1, as the backside does
if (typeof Plate !== 'undefined' && Plate.isPlateId(matchId)) {
const r = matchId.slice(2);
return 500 + (r === 'F' ? 100 : r === 'B' ? 99 : r.startsWith('SF') ? 30 : r.startsWith('QF') ? 20 : parseInt(r.slice(1)) || 1);
}
// Groups and cups: group matches first, then each cup's rounds (A-R1-3, A-QF1, A-SF1, A-B, A-F)
if (typeof Groups !== 'undefined' && Groups.isGroupId(matchId)) return 0;
if (typeof Groups !== 'undefined' && Groups.isCupId(matchId)) {
const r = matchId.slice(2);
return r === 'F' ? 1000 : r === 'B' ? 999 : r.startsWith('SF') ? 30 : r.startsWith('QF') ? 20 : parseInt(r.slice(1)) || 1;
}
if (matchId === 'FINAL' || matchId === 'GRAND-FINAL') return 1000;
if (matchId === 'BRONZE') return 999;
if (matchId === 'BS-FINAL') return 900;
const fsParts = matchId.match(/^FS-(\d+)-/);
if (fsParts) return parseInt(fsParts[1]);
const bsParts = matchId.match(/^BS-(\d+)-/);
if (bsParts) return 500 + parseInt(bsParts[1]);
return 0;
}
/**
* Returns true if this is the specific match where the player was eliminated
* (has a placement AND did not appear in any later-round completed match).
* Works correctly for both SE (placement = only trigger in elimination round) and
* DE (frontside losers continue to backside, so their strikethrough is on the
* backside loss, not the earlier frontside loss).
* @param {object} match - The match object
* @param {string} playerSlot - 'player1' or 'player2'
* @returns {boolean}
*/
function isPlayerEliminatedInMatch(match, playerSlot) {
if (!match.completed) return false;
const playerObj = match[playerSlot];
if (!playerObj || !playerObj.name || playerObj.name === 'Walkover') return false;
if (match.winner?.id === playerObj.id) return false; // winner, not loser
if (!tournament.placements?.[String(playerObj.id)]) return false; // no placement = still competing
// Show strikethrough only in the player's LAST completed match.
// If they appear in any completed match with a higher round order, that is their
// actual elimination match and this one is not (e.g. DE frontside vs backside).
const matchOrder = getMatchRoundOrder(match.id);
// The live global, not tournament.matches β nothing ever assigns that, so it is
// frozen at whatever it held when the tournament object was built (usually empty).
// Reading it made this scan find nothing, striking through a player in every match
// they lost rather than only their elimination.
const allMatches = (typeof matches !== 'undefined' && Array.isArray(matches)) ? matches : [];
const eliminatedLater = allMatches.some(m =>
m.id !== match.id &&
m.completed &&
getMatchRoundOrder(m.id) > matchOrder &&
(m.player1?.id === playerObj.id || m.player2?.id === playerObj.id)
);
return !eliminatedLater;
}
// HELPER FUNCTIONS
/**
* Determines the current state of a match for UI rendering.
*
* @param {Match} match - The match object to evaluate
* @returns {MatchState} 'pending', 'ready', 'live', or 'completed'
*
* @description
* - pending: Waiting for players (TBD slots)
* - ready: Both players assigned, can start
* - live: Match in progress (active = true)
* - completed: Match finished with winner/loser
*/
function getMatchState(match) {
if (!match) return 'pending';
if (match.completed) return 'completed';
if (match.active) return 'live';
// Check if both players are ready
if (canMatchStart && canMatchStart(match)) return 'ready';
return 'pending';
}
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;
if (!player1Valid || !player2Valid) return false;
// SE Final gating: can't start the Final until Bronze is completed
if (typeof isSEFinalMatch === 'function' && typeof isSEBronzeMatch === 'function' &&
tournament && tournament.format === 'SE' &&
isSEFinalMatch(match.id, tournament.bracketSize)) {
const bronzeMatch = matches.find(m => isSEBronzeMatch(m.id, tournament.bracketSize));
if (bronzeMatch && !bronzeMatch.completed) {
return false;
}
}
// Groups and cups: each cup's final waits for its bronze final, as in single elimination
if (tournament && tournament.format === 'GROUPS' && match.side === 'cup' && /-F$/.test(match.id)) {
const bronzeMatch = matches.find(m => m.id === `${match.cup}-B`);
if (bronzeMatch && !bronzeMatch.completed) return false;
}
return true;
}
function getButtonClickHandler(matchState, matchId) {
if (matchState === 'pending' || matchState === 'completed') {
return '';
}
const functionName = typeof toggleActiveWithValidation !== 'undefined' ?
'toggleActiveWithValidation' : 'toggleActive';
return `${functionName}('${matchId}')`;
}
// ZOOM β the header's buttons; the bracket view owns the camera
/** Zoom in on the bracket. @returns {void} */
function zoomIn() { BracketView.zoomIn(); }
/** Zoom out on the bracket (never past Fit all). @returns {void} */
function zoomOut() { BracketView.zoomOut(); }
/** Fit the whole bracket (the header's Fit all). @returns {void} */
function resetZoom() { BracketView.fitAll(); }
// --- START: Functions for Referee and Lane Management ---
function getAssignedReferees(excludeMatchId = null) {
if (!matches || matches.length === 0) return [];
const assignedReferees = [];
// Groups and cups: a referee is only taken while the match is live; one chosen for a match that
// hasn't started is a plan (Match Controls holds the matches that depend on it instead)
const liveOnly = typeof tournament !== 'undefined' && tournament && tournament.format === 'GROUPS';
matches.forEach(match => {
if (excludeMatchId && match.id === excludeMatchId) return;
if (match.referee && !match.completed && (!liveOnly || match.active)) {
assignedReferees.push(parseInt(match.referee));
}
});
return assignedReferees;
}
function getPlayersInLiveMatches(excludeMatchId = null) {
if (!matches || matches.length === 0) return [];
const playersInLiveMatches = [];
matches.forEach(match => {
if (excludeMatchId && match.id === excludeMatchId) return;
if (getMatchState(match) === 'live') {
if (match.player1 && match.player1.id && !match.player1.isBye) {
playersInLiveMatches.push(parseInt(match.player1.id));
}
if (match.player2 && match.player2.id && !match.player2.isBye) {
playersInLiveMatches.push(parseInt(match.player2.id));
}
}
});
return playersInLiveMatches;
}
function isPlayerAvailableAsReferee(playerId, excludeMatchId = null) {
const assignedReferees = getAssignedReferees(excludeMatchId);
const playersInLiveMatches = getPlayersInLiveMatches(excludeMatchId);
const playerIdInt = parseInt(playerId);
return !assignedReferees.includes(playerIdInt) && !playersInLiveMatches.includes(playerIdInt);
}
/**
* Check if a match has referee conflicts (players are refereeing other matches)
* @param {string} matchId - The match ID to check
* @returns {Object} - {hasConflict: boolean, player1IsReferee: boolean, player2IsReferee: boolean, conflictedPlayers: string[]}
*/
function checkRefereeConflict(matchId) {
const match = matches.find(m => m.id === matchId);
if (!match || !match.player1 || !match.player2) {
return { hasConflict: false, player1IsReferee: false, player2IsReferee: false, conflictedPlayers: [] };
}
const player1Id = match.player1.id;
const player2Id = match.player2.id;
let player1IsReferee = false;
let player2IsReferee = false;
const conflictedPlayers = [];
// Groups and cups: only a live match's referee is busy (see getAssignedReferees())
const liveOnly = tournament && tournament.format === 'GROUPS';
if (matches && player1Id && player2Id) {
matches.forEach(m => {
// Skip the current match - players can referee their own matches
if (m.id === matchId) return;
const matchState = getMatchState(m);
if ((matchState === 'live' || (matchState === 'ready' && !liveOnly)) && m.referee) {
if (m.referee === player1Id) {
player1IsReferee = true;
if (!conflictedPlayers.includes(match.player1.name)) {
conflictedPlayers.push(match.player1.name);
}
}
if (m.referee === player2Id) {
player2IsReferee = true;
if (!conflictedPlayers.includes(match.player2.name)) {
conflictedPlayers.push(match.player2.name);
}
}
}
});
}
return {
hasConflict: player1IsReferee || player2IsReferee,
player1IsReferee,
player2IsReferee,
conflictedPlayers
};
}
function updateMatchReferee(matchId, refereeId) {
const match = matches.find(m => m.id === matchId);
if (!match) return false;
let parsedRefereeId = null;
let description;
if (!refereeId) {
// Clear referee
match.referee = null;
description = `${matchId}: Referee cleared`;
} else {
// Assign referee β validate availability first
const currentRefereeId = match.referee;
if (refereeId !== currentRefereeId && !isPlayerAvailableAsReferee(refereeId, matchId)) {
alert('This referee is already assigned to another match or currently playing.');
// Put the Match Controls dropdown back to the current referee
const dropdown = document.querySelector(`#cc-match-card-${matchId} select[onchange*="updateMatchReferee"]`);
if (dropdown) dropdown.value = currentRefereeId || '';
return false;
}
parsedRefereeId = parseInt(refereeId);
match.referee = parsedRefereeId;
const referee = players && players.find(p => p.id === parsedRefereeId);
const refereeName = referee ? referee.name : 'Unknown';
description = `${matchId}: Referee assigned to ${refereeName} (ID: ${parsedRefereeId})`;
}
// Shared path: record the transaction, persist, re-render, refresh Match Controls.
if (!window.rebuildInProgress && typeof saveTransaction === 'function') {
saveTransaction({
id: generateTransactionId(),
type: 'ASSIGN_REFEREE',
description: description,
timestamp: new Date().toISOString(),
matchId: matchId,
afterState: { referee: parsedRefereeId } // Keep for referee suggestions timeline
});
}
saveTournament();
// Re-render the bracket to update referee conflict markers; Match Controls (below)
// rebuilds its own referee dropdowns
renderBracket();
// Refresh Match Controls if it is open
const modal = document.getElementById('matchCommandCenterModal');
if (modal &&
(modal.style.display === 'flex' || modal.style.display === 'block') &&
typeof showMatchCommandCenter === 'function') {
setTimeout(() => {
showMatchCommandCenter();
}, 200);
}
return true;
}
/**
* Subtract one recorded achievements delta from a stats object (player.stats, or a snapshot from
* snapshotPlayerStats()). Counters are floored at zero; array fields remove each recorded value.
* @param {{ oneEighties?: number, tons?: number, lollipops?: number, highOuts?: number[], shortLegs?: number[] }} stats - changed in place
* @param {{ oneEighties?: number, tons?: number, lollipops?: number, highOuts?: number[], shortLegs?: number[] }} delta
*/
function subtractAchievementStats(stats, delta) {
if (delta.oneEighties) stats.oneEighties = Math.max(0, (stats.oneEighties || 0) - delta.oneEighties);
if (delta.tons) stats.tons = Math.max(0, (stats.tons || 0) - delta.tons);
if (delta.lollipops) stats.lollipops = Math.max(0, (stats.lollipops || 0) - delta.lollipops);
['highOuts', 'shortLegs'].forEach(field => {
if (!Array.isArray(delta[field])) return;
delta[field].forEach(val => {
const idx = (stats[field] || []).indexOf(val);
if (idx !== -1) stats[field].splice(idx, 1);
});
});
}
/**
* Rolls back achievements recorded in a COMPLETE_MATCH transaction from player.stats.
* Subtracts exactly what the transaction recorded; counters are floored at zero.
* @param {object} achievements - { [playerId]: { oneEighties, tons, lollipops, highOuts, shortLegs } }
*/
function rollbackAchievements(achievements) {
if (!achievements) return;
Object.entries(achievements).forEach(([playerId, delta]) => {
if (!delta) return;
const player = players.find(p => String(p.id) === String(playerId));
if (!player || !player.stats) return;
subtractAchievementStats(player.stats, delta);
});
saveTournament();
if (typeof updateResultsTable === 'function') updateResultsTable();
console.log('π Achievement rollback complete');
}
/**
* Returns a human-readable label for a match's bracket position.
* @param {string} matchId - e.g. 'FS-1-1', 'BS-2-3', 'GRAND-FINAL', 'BS-FINAL'
* @returns {string}
*/
function _bracketLabel(matchId) {
if (typeof getFormat === 'function' && getFormat() === 'GROUPS' && typeof Groups !== 'undefined') return Groups.roundName(matchId);
if (matchId === 'GRAND-FINAL') return 'Grand Final';
if (matchId === 'BS-FINAL') return 'Backside Final';
if (matchId.startsWith('FS-')) return `Frontside Round ${matchId.split('-')[1]}`;
if (matchId.startsWith('BS-')) return `Backside Round ${matchId.split('-')[1]}`;
return matchId;
}
/**
* Builds a consequence match card safely (no innerHTML / no template interpolation of user data).
*/
function _buildUndoMatchCard(id, match, isFrontside) {
const card = document.createElement('div');
card.className = 'undo-match-card';
const header = document.createElement('div');
header.className = 'undo-match-header';
const idDiv = document.createElement('div');
idDiv.className = 'undo-match-id';
idDiv.textContent = id;
header.appendChild(idDiv);
const typeDiv = document.createElement('div');
typeDiv.className = 'undo-bracket-type';
if (typeof getFormat === 'function' && getFormat() === 'GROUPS' && typeof Groups !== 'undefined') {
typeDiv.textContent = Groups.roundName(match);
} else if (match.id === 'GRAND-FINAL' || match.id === 'BS-FINAL') {
typeDiv.textContent = match.id;
} else if (isFrontside) {
typeDiv.textContent = `βͺ Frontside - Round ${match.id.split('-')[1]}`;
} else {
typeDiv.textContent = `β« Backside - Round ${match.id.split('-')[1]}`;
}
header.appendChild(typeDiv);
card.appendChild(header);
const playersDiv = document.createElement('div');
playersDiv.className = 'undo-match-players';
const p1 = match.player1?.name || 'TBD';
const p2 = match.player2?.name || 'TBD';
playersDiv.textContent = `${p1} vs ${p2}`;
card.appendChild(playersDiv);
return card;
}
/**
* Builds the achievements section safely.
*/
function _buildUndoAchievementsSection(achievements, isQR) {
const section = document.createElement('div');
section.className = 'undo-achievements';
const title = document.createElement('div');
title.className = 'undo-achievements-title';
title.textContent = `Recorded achievements for this match${isQR ? ' (Chalker)' : ' (Manual)'}:`;
section.appendChild(title);
Object.entries(achievements).forEach(([playerId, stats]) => {
if (!stats) return;
const player = players.find(p => String(p.id) === String(playerId));
const name = player?.name || `Player ${playerId}`;
const lines = [];
if (stats.oneEighties) lines.push(`${stats.oneEighties}Γ 180`);
if (stats.tons) lines.push(`${stats.tons}Γ ton`);
if (stats.highOuts?.length) lines.push(stats.highOuts.map(v => `high out (${v})`).join(', '));
if (stats.shortLegs?.length) lines.push(`${stats.shortLegs.length}Γ short leg`);
if (stats.lollipops) lines.push(`${stats.lollipops}Γ lollipop`);
if (!lines.length) return;
const row = document.createElement('div');
row.className = 'undo-achievement-row';
const nameBold = document.createElement('strong');
nameBold.textContent = `${name}:`;
row.appendChild(nameBold);
row.appendChild(document.createTextNode(` ${lines.join(', ')}`));
section.appendChild(row);
});
return section;
}
/**
* Show the undo confirmation modal.
* @param {object} opts
* @param {string} opts.matchId - The match being undone (e.g. 'FS-1-1')
* @param {Array} opts.consequentialMatches - Other matches that will reset
* @param {object} opts.transaction - The transaction being undone (carries achievements + completionType)
* @param {Function} opts.onConfirm - Called when "Undo match" is clicked
* @param {Function|null} [opts.onCancel] - Called when cancelled (button, Esc, or X)
* @param {Function|null} [opts.onConfirmWithAchievements] - When provided, shows "Undo match + achievements" button
*/
function showUndoConfirmationModal({matchId, consequentialMatches, transaction, onConfirm, onCancel = null, onConfirmWithAchievements = null}) {
const cancelBtn = document.getElementById('undoConfirmCancel');
const confirmBtn = document.getElementById('undoConfirmOK');
const confirmWithAchievementsBtn = document.getElementById('undoConfirmOKWithAchievements');
const body = document.getElementById('undoConsequences');
if (!cancelBtn || !confirmBtn || !confirmWithAchievementsBtn || !body) {
// Never fall through to the destructive action without user confirmation
console.error('Undo confirmation modal elements not found - undo aborted');
return;
}
// Sidebar β match metadata (safe via textContent)
const matchObj = matches.find(m => m.id === matchId);
const p1 = matchObj?.player1?.name || 'TBD';
const p2 = matchObj?.player2?.name || 'TBD';
document.getElementById('undoSidebarMatch').textContent = matchId;
document.getElementById('undoSidebarBracket').textContent = _bracketLabel(matchId);
document.getElementById('undoSidebarPlayers').textContent = `${p1} vs ${p2}`;
// Build consequences body safely (no innerHTML / no interpolation of user data)
body.replaceChildren();
if (!consequentialMatches || consequentialMatches.length === 0) {
const noMatches = document.createElement('div');
noMatches.className = 'undo-no-matches';
noMatches.textContent = 'No other matches will be affected.';
body.appendChild(noMatches);
} else {
const header = document.createElement('div');
header.className = 'undo-header';
header.textContent = `Undoing ${matchId} will reset the following matches:`;
body.appendChild(header);
const container = document.createElement('div');
container.className = 'undo-matches-container';
consequentialMatches.forEach(({id, match: cMatch, isFrontside}) => {
container.appendChild(_buildUndoMatchCard(id, cMatch, isFrontside));
});
body.appendChild(container);
}
// Optional achievements section + info/warning footer line
const achievements = transaction?.achievements || {};
const isQR = transaction?.completionType === 'QR';
const hasAchievements = Object.values(achievements).some(a => a !== null);
if (hasAchievements) {
body.appendChild(_buildUndoAchievementsSection(achievements, isQR));
}
const footerLine = document.createElement('div');
if (isQR) {
footerLine.className = 'undo-achievements-info';
footerLine.textContent = 'Achievements were recorded automatically from Chalker visit scores.';
} else {
footerLine.className = 'undo-achievements-warning';
footerLine.textContent = 'β οΈ Achievements may have been entered manually for these players. Review the leaderboard.';
}
body.appendChild(footerLine);
// Show/hide the "+ achievements" button
confirmWithAchievementsBtn.style.display = onConfirmWithAchievements ? '' : 'none';
// Wire buttons + Esc β close via popDialog and fire user callbacks
const handleEscape = (e) => {
if (e.key === 'Escape') closeAndCancel();
};
const cleanup = () => {
cancelBtn.onclick = null;
confirmBtn.onclick = null;
confirmWithAchievementsBtn.onclick = null;
document.removeEventListener('keydown', handleEscape);
};
const closeAndCancel = () => { cleanup(); popDialog(); if (onCancel) onCancel(); };
cancelBtn.onclick = closeAndCancel;
confirmBtn.onclick = () => { cleanup(); popDialog(); onConfirm(); };
if (onConfirmWithAchievements) {
confirmWithAchievementsBtn.onclick = () => { cleanup(); popDialog(); onConfirmWithAchievements(); };
}
// Show modal (Esc handled manually so it routes through onCancel for debounce cleanup)
pushDialog('undoConfirmModal', null, false);
document.addEventListener('keydown', handleEscape);
// Focus Cancel for safety; inline boxShadow overrides the global outline-none rule
setTimeout(() => {
cancelBtn.focus();
cancelBtn.style.boxShadow = '0 0 0 3px rgba(108, 117, 125, 0.3)';
}, 100);
}
// --- END: Functions for Referee and Lane Management ---
// UNDO SYSTEM FUNCTIONS - Refactored for Transactional History
/**
* Refresh all tournament UI components after a state change (like undo).
*/
function refreshTournamentUI() {
try {
// Update tournament status display
if (typeof updateTournamentStatus === 'function') {
updateTournamentStatus();
}
// Update players display and count
if (typeof updatePlayersDisplay === 'function') {
updatePlayersDisplay();
}
if (typeof updatePlayerCount === 'function') {
updatePlayerCount();
}
// Force re-render bracket
if (typeof renderBracket === 'function') {
renderBracket();
}
// Update match states
if (typeof updateAllMatchStates === 'function') {
updateAllMatchStates();
}
// Update results table
if (typeof displayResults === 'function') {
displayResults();
}
// Refresh lane dropdowns if available
if (typeof refreshAllLaneDropdowns === 'function') {
setTimeout(refreshAllLaneDropdowns, 100);
}
// Save tournament
if (typeof saveTournament === 'function') {
saveTournament();
}
} catch (error) {
console.error('Error during UI refresh:', error);
}
}
/**
* Generate referee dropdown options with conflict detection.
*/
function generateRefereeOptionsWithConflicts(currentMatchId, currentRefereeId = null) {
let options = '';
if (typeof players !== 'undefined' && Array.isArray(players)) {
const paidPlayers = players.filter(player => player.paid);
const sortedPlayers = paidPlayers.sort((a, b) => a.name.localeCompare(b.name));
const assignedReferees = getAssignedReferees(currentMatchId);
const playersInLiveMatches = getPlayersInLiveMatches(currentMatchId);
sortedPlayers.forEach(player => {
const playerId = parseInt(player.id);
const isCurrentReferee = currentRefereeId && playerId === parseInt(currentRefereeId);
const isAssignedElsewhere = assignedReferees.includes(playerId);
const isInLiveMatch = playersInLiveMatches.includes(playerId);
if (isCurrentReferee || (!isAssignedElsewhere && !isInLiveMatch)) {
const selected = isCurrentReferee ? 'selected' : '';
options += ``;
} else {
let reason = isAssignedElsewhere ? ' (assigned)' : ' (playing)';
options += ``;
}
});
}
return options;
}
// Make functions globally available
if (typeof window !== 'undefined') {
window.refreshTournamentUI = refreshTournamentUI;
window.undoCupDraw = undoCupDraw;
window.undoCupDrawConfirmed = undoCupDrawConfirmed;
// Original functions needed by HTML
window.updateMatchReferee = updateMatchReferee;
window.generateRefereeOptionsWithConflicts = generateRefereeOptionsWithConflicts;
window.getAssignedReferees = getAssignedReferees;
window.getPlayersInLiveMatches = getPlayersInLiveMatches;
window.isPlayerAvailableAsReferee = isPlayerAvailableAsReferee;
window.checkRefereeConflict = checkRefereeConflict;
}
// --- START: Surgical Undo Implementation ---
/**
* Finds the downstream matches that block undoing a completed match (6.8).
*
* Single source for the "is anything downstream in the way?" scan. Undoing a match
* un-advances its winner and loser, so it is unsafe once a destination match has
* started (live) or has a result a human/Chalker entered (MANUAL/QR). An AUTO
* (walkover) completion downstream does not block β it is re-derivable.
*
* Both callers derive their answer from this list rather than repeating the walk:
* isMatchUndoable() only asks whether it is empty; getDetailedMatchState() turns it
* into the "Cannot Undo, blocked by ..." status text. The two had byte-identical
* copies of this scan that had to be kept in lockstep β and both carried the same
* bug when QR completions were introduced.
*
* Does NOT decide undo eligibility on its own: each caller keeps its own read-only,
* walkover and "has a MANUAL/QR transaction" gates, which differ in what they return.
*
* @param {string} matchId - The match ID being considered for undo (e.g., 'FS-1-1')
* @param {Array