import { Utils } from '../../lib'; interface ElimTree { root: ElimNode; currentLayerLeafNodes: ElimNode[]; nextLayerLeafNodes: ElimNode[]; } import type { TournamentPlayer } from './index'; /** * There are two types of elim nodes, player nodes * and match nodes. * * Player nodes are leaf nodes: .children = none * * Match nodes are non-leaf nodes, and will always have two children. */ class ElimNode { children: [ElimNode, ElimNode] | null; /** * In a player node, the player (null if it's an unfilled loser's bracket node). * * In a match node, the winner if it exists, otherwise null. */ user: TournamentPlayer | null; /** * Only relevant to match nodes. (Player nodes are always '') * * 'available' = ready for battles - will have two children, both with users; this.user is null * * 'finished' = battle already over - will have two children, both with users; this.user is winner * * '' = unavailable */ state: 'available' | 'finished' | ''; result: 'win' | 'loss' | ''; score: number[] | null; /** * Only relevant to match nodes in double+ elimination. * * The loser of this battle will be put in target player node. */ losersBracketNode: ElimNode | null; /** * 0 = winner's bracket * 1 = loser's bracket * 2 = second loser's bracket * etc * (always 0 in single elimination) */ losersBracketIndex: number; parent: ElimNode | null; /** * Only used while building the tree */ fromNode: ElimNode | null; constructor(options: Partial) { this.children = null; this.user = options.user || null; this.state = options.state || ''; this.result = options.result || ''; this.score = options.score || null; this.losersBracketNode = options.losersBracketNode || null; this.losersBracketIndex = options.losersBracketIndex || 0; this.parent = options.parent || null; this.fromNode = options.fromNode || null; } setChildren(children: [ElimNode, ElimNode] | null) { if (this.children) { for (const child of this.children) child.parent = null; } if (children) { for (const child of children) child.parent = this; } this.children = children; } traverse(multiCallback: (node: ElimNode) => void) { const queue: ElimNode[] = [this]; let node; while ((node = queue.shift())) { multiCallback(node); if (node.children) queue.push(...node.children); } } find(multiCallback: (node: ElimNode) => (T | void)) { const queue: ElimNode[] = [this]; let node; while ((node = queue.shift())) { const value = multiCallback(node); if (value) { return value; } if (node.children) queue.push(...node.children); } return undefined; } [Symbol.iterator]() { const results: ElimNode[] = [this]; for (const result of results) { if (result.children) results.push(...result.children); } return results[Symbol.iterator](); } toJSON() { const node: any = {}; if (!this.children) { node.team = this.user || ( this.losersBracketIndex <= 1 ? `(loser's bracket)` : `(loser's bracket ${this.losersBracketIndex})` ); } else { node.children = this.children.map(child => child.toJSON()); node.state = this.state || 'unavailable'; if (node.state === 'finished') { node.team = this.user; node.result = this.result; node.score = this.score; } } return node; } } const nameMap = [ "", "Single", "Double", "Triple", "Quadruple", "Quintuple", "Sextuple", // Feel free to add more ]; export class Elimination { readonly name: string; readonly isDrawingSupported: boolean; isBracketFrozen: boolean; players: TournamentPlayer[]; maxSubtrees: number; treeRoot: ElimNode; constructor(maxSubtrees: number | string) { this.name = "Elimination"; this.isDrawingSupported = false; this.isBracketFrozen = false; this.players = []; maxSubtrees = maxSubtrees || 1; if (typeof maxSubtrees === 'string' && maxSubtrees.toLowerCase() === 'infinity') { maxSubtrees = Infinity; } else if (typeof maxSubtrees !== 'number') { maxSubtrees = parseInt(maxSubtrees); } if (!maxSubtrees || maxSubtrees < 1) maxSubtrees = 1; this.maxSubtrees = maxSubtrees; this.treeRoot = null!; if (nameMap[maxSubtrees]) { this.name = `${nameMap[maxSubtrees]} ${this.name}`; } else if (maxSubtrees === Infinity) { this.name = `N-${this.name}`; } else { this.name = `${maxSubtrees}-tuple ${this.name}`; } } getPendingBracketData(players: TournamentPlayer[]) { return { type: 'tree', rootNode: null, }; } getBracketData() { return { type: 'tree', rootNode: this.treeRoot.toJSON(), }; } freezeBracket(players: TournamentPlayer[]) { if (!players.length) throw new Error(`No players in tournament`); this.players = players; this.isBracketFrozen = true; // build the winner's bracket let tree: ElimTree = null!; for (const user of Utils.shuffle(players)) { if (!tree) { tree = { root: new ElimNode({ user }), currentLayerLeafNodes: [], nextLayerLeafNodes: [], }; tree.currentLayerLeafNodes.push(tree.root); continue; } const targetNode = tree.currentLayerLeafNodes.shift(); if (!targetNode) throw new Error(`TypeScript bug: no ! in checkJs`); const newLeftChild = new ElimNode({ user: targetNode.user }); tree.nextLayerLeafNodes.push(newLeftChild); const newRightChild = new ElimNode({ user }); tree.nextLayerLeafNodes.push(newRightChild); targetNode.setChildren([newLeftChild, newRightChild]); targetNode.user = null; if (tree.currentLayerLeafNodes.length === 0) { tree.currentLayerLeafNodes = tree.nextLayerLeafNodes; tree.nextLayerLeafNodes = []; } } // build the loser's brackets, if applicable this.maxSubtrees = Math.min(this.maxSubtrees, players.length - 1); for (let losersBracketIndex = 1; losersBracketIndex < this.maxSubtrees; losersBracketIndex++) { const matchesByDepth: { [depth: number]: ElimNode[] } = {}; const queue = [{ node: tree.root, depth: 0 }]; let frame; while ((frame = queue.shift())) { if (!frame.node.children || frame.node.losersBracketNode) continue; if (!matchesByDepth[frame.depth]) matchesByDepth[frame.depth] = []; matchesByDepth[frame.depth].push(frame.node); queue.push({ node: frame.node.children[0], depth: frame.depth + 1 }); queue.push({ node: frame.node.children[1], depth: frame.depth + 1 }); } const newTree: ElimTree = { root: new ElimNode({ losersBracketIndex, fromNode: matchesByDepth[0][0] }), currentLayerLeafNodes: [], nextLayerLeafNodes: [], }; newTree.currentLayerLeafNodes.push(newTree.root); for (const depth in matchesByDepth) { if (depth === '0') continue; const matchesThisDepth = matchesByDepth[depth]; let n = 0; for (; n < matchesThisDepth.length - 1; n += 2) { // Replace old leaf with: // old leaf --+ // new leaf --+ +--> // +--+ // new leaf --+ const oldLeaf = newTree.currentLayerLeafNodes.shift(); if (!oldLeaf) throw new Error(`TypeScript bug: no ! in checkJs`); const oldLeafFromNode = oldLeaf.fromNode; oldLeaf.fromNode = null; const newBranch = new ElimNode({ losersBracketIndex }); oldLeaf.setChildren([new ElimNode({ losersBracketIndex, fromNode: oldLeafFromNode }), newBranch]); const newLeftChild = new ElimNode({ losersBracketIndex, fromNode: matchesThisDepth[n] }); newTree.nextLayerLeafNodes.push(newLeftChild); const newRightChild = new ElimNode({ losersBracketIndex, fromNode: matchesThisDepth[n + 1] }); newTree.nextLayerLeafNodes.push(newRightChild); newBranch.setChildren([newLeftChild, newRightChild]); } if (n < matchesThisDepth.length) { // Replace old leaf with: // old leaf --+ // +--> // new leaf --+ const oldLeaf = newTree.currentLayerLeafNodes.shift()!; const oldLeafFromNode = oldLeaf.fromNode; oldLeaf.fromNode = null; const newLeaf = new ElimNode({ fromNode: matchesThisDepth[n] }); newTree.nextLayerLeafNodes.push(newLeaf); oldLeaf.setChildren([new ElimNode({ fromNode: oldLeafFromNode }), newLeaf]); } newTree.currentLayerLeafNodes = newTree.nextLayerLeafNodes; newTree.nextLayerLeafNodes = []; } newTree.root.traverse(node => { if (node.fromNode) { node.fromNode.losersBracketNode = node; node.fromNode = null; } }); const newRoot = new ElimNode({}); newRoot.setChildren([tree.root, newTree.root]); tree.root = newRoot; } tree.root.traverse(node => { if (node.children?.[0].user && node.children[1].user) { node.state = 'available'; } }); this.treeRoot = tree.root; } disqualifyUser(user: TournamentPlayer) { if (!this.isBracketFrozen) return 'BracketNotFrozen'; /** * The user either has a single available battle or no available battles */ const found = this.treeRoot.find(node => { if (node.state === 'available') { if (!node.children) throw new Error(`no children`); if (node.children[0].user === user) { return { match: [user, node.children[1].user], result: 'loss', score: [0, 1], }; } else if (node.children[1].user === user) { return { match: [node.children[0].user, user], result: 'win', score: [1, 0], }; } } return undefined; }); if (found) { // @ts-expect-error TODO: refactor to fix this const error = this.setMatchResult(found.match, found.result, found.score); if (error) { throw new Error(`Unexpected ${error} from setMatchResult([${found.match.join(', ')}], ${found.result})`); } } user.game.setPlayerUser(user, null); } getAvailableMatches() { if (!this.isBracketFrozen) return 'BracketNotFrozen'; const matches: [TournamentPlayer, TournamentPlayer][] = []; this.treeRoot.traverse(node => { if (node.state !== 'available') return; const p1 = node.children![0].user!; const p2 = node.children![1].user!; if (!p1.isBusy && !p2.isBusy) { matches.push([p1, p2]); } }); return matches; } setMatchResult([p1, p2]: [TournamentPlayer, TournamentPlayer], result: 'win' | 'loss', score: number[]) { if (!this.isBracketFrozen) return 'BracketNotFrozen'; if (!['win', 'loss'].includes(result)) return 'InvalidMatchResult'; if (!this.players.includes(p1) || !this.players.includes(p2)) return 'UserNotAdded'; const targetNode = this.treeRoot.find(node => { if (node.state === 'available' && ( node.children![0].user === p1 && node.children![1].user === p2 )) { return node; } return undefined; }); if (!targetNode) return 'InvalidMatch'; if (!targetNode.children) throw new Error(`invalid available state`); targetNode.state = 'finished'; targetNode.result = result; targetNode.score = score.slice(); const winner = targetNode.children[result === 'win' ? 0 : 1].user; const loser = targetNode.children[result === 'loss' ? 0 : 1].user; targetNode.user = winner; if (!winner || !loser) throw new Error(`invalid available state`); if (loser.losses === this.maxSubtrees) { loser.isEliminated = true; loser.sendRoom(`|tournament|update|{"isJoined":false}`); loser.game.setPlayerUser(loser, null); } if (targetNode.parent) { const parent = targetNode.parent; if (loser.losses <= winner.losses && !loser.isDisqualified) { // grand subfinals rematch const newNode = new ElimNode({ state: 'available', losersBracketNode: targetNode.losersBracketNode }); newNode.setChildren([targetNode, new ElimNode({ user: loser })]); parent.setChildren([newNode, parent.children![1]]); return; } const userA = parent.children![0].user; const userB = parent.children![1].user; if (userA && userB) { parent.state = 'available'; let error: string | undefined = ''; if (userA.isDisqualified) { error = this.setMatchResult([userA, userB], 'loss', [0, 1]); } else if (userB.isDisqualified) { error = this.setMatchResult([userA, userB], 'win', [1, 0]); } if (error) { throw new Error(`Unexpected ${error} from setMatchResult([${userA},${userB}], ...)`); } } } else if (loser.losses < this.maxSubtrees && !loser.isDisqualified) { const newRoot = new ElimNode({ state: 'available' }); newRoot.setChildren([targetNode, new ElimNode({ user: loser })]); this.treeRoot = newRoot; } if (targetNode.losersBracketNode) { targetNode.losersBracketNode.user = loser; const userA = targetNode.losersBracketNode.parent!.children![0].user; const userB = targetNode.losersBracketNode.parent!.children![1].user; if (userA && userB) { targetNode.losersBracketNode.parent!.state = 'available'; let error: string | undefined = ''; if (userA.isDisqualified) { error = this.setMatchResult([userA, userB], 'loss', [0, 1]); } else if (userB.isDisqualified) { error = this.setMatchResult([userA, userB], 'win', [1, 0]); } if (error) { throw new Error(`Unexpected ${error} from setMatchResult([${userA}, ${userB}], ...)`); } } } } isTournamentEnded() { return this.treeRoot.state === 'finished'; } getResults() { if (!this.isTournamentEnded()) return 'TournamentNotEnded'; const results = []; let currentNode = this.treeRoot; for (let n = 0; n < this.maxSubtrees; ++n) { results.push([currentNode.user]); if (!currentNode.children) break; currentNode = currentNode.children[currentNode.result === 'loss' ? 0 : 1]; if (!currentNode) break; } if (this.players.length - 1 === this.maxSubtrees && currentNode) { results.push([currentNode.user]); } return results; } }