{"version":3,"file":"index.ccac3967.js","sources":["../../vite/modulepreload-polyfill","../../src/state/TeamState.ts","../../src/state/PlayerUtils.ts","../../src/state/Cycle.ts","../../src/state/GameFormats.ts","../../src/parser/FormattedTextParser.ts","../../src/state/PacketState.ts","../../src/state/GameState.ts","../../src/qbj/QBJ.ts","../../src/state/CustomExport.ts","../../src/state/IPendingNewGame.ts","../../src/state/SheetState.ts","../../src/state/AddQuestionsDialogState.ts","../../src/state/CustomizeGameFormatDialogState.ts","../../src/state/IMessageDialogState.ts","../../src/state/RenamePlayerDialogState.ts","../../src/state/ReorderPlayersDialogState.ts","../../src/state/FontDialogState.ts","../../src/state/ModalVisibilityStatus.ts","../../src/state/RenameTeamDialogState.ts","../../src/state/DialogState.ts","../../src/state/UIState.ts","../../src/state/StatusDisplayType.ts","../../src/state/AppState.ts","../../src/components/PacketLoaderController.ts","../../react/jsx-runtime","../../src/contexts/StateContext.tsx","../../src/components/CycleChooser.tsx","../../src/components/TossupQuestionController.ts","../../src/components/FormattedText.tsx","../../src/components/QuestionWord.tsx","../../src/components/BuzzMenu.tsx","../../src/components/Answer.tsx","../../src/components/dialogs/TossupProtestDialogController.ts","../../src/components/dialogs/ModalDialog.tsx","../../src/components/dialogs/ProtestDialogBase.tsx","../../src/components/dialogs/TossupProtestDialog.tsx","../../src/components/CancelButton.tsx","../../src/components/PostQuestionMetadata.tsx","../../src/components/TossupQuestion.tsx","../../src/components/BonusQuestionController.ts","../../src/components/BonusQuestionPart.tsx","../../src/components/dialogs/BonusProtestDialogController.ts","../../src/components/dialogs/BonusProtestDialog.tsx","../../src/components/BonusQuestion.tsx","../../src/components/QuestionViewer.tsx","../../src/components/QuestionViewerContainer.tsx","../../src/components/Scoreboard.tsx","../../src/components/cycleItems/CycleItem.tsx","../../src/components/cycleItems/PlayerLeaveCycleItem.tsx","../../src/components/cycleItems/PlayerJoinsCycleItem.tsx","../../src/components/cycleItems/SubstitutionCycleItem.tsx","../../src/components/cycleItems/TossupAnswerCycleItem.tsx","../../src/components/cycleItems/ThrowOutQuestionCycleItem.tsx","../../src/components/cycleItems/BonusAnswerCycleItem.tsx","../../src/components/cycleItems/TossupProtestCycleItem.tsx","../../src/components/cycleItems/BonusProtestCycleItem.tsx","../../src/components/cycleItems/CycleItemList.tsx","../../src/components/EventViewer.tsx","../../src/components/dialogs/ReorderTeamsDialogController.ts","../../src/components/GameBar.tsx","../../src/components/Clock.tsx","../../src/components/ExportStatus.tsx","../../src/components/GameViewer.tsx","../../src/state/PendingNewGameUtils.ts","../../src/state/NewGameValidator.ts","../../src/components/dialogs/AddPlayerDialogController.ts","../../src/components/dialogs/AddPlayerDialog.tsx","../../src/sheets/PlayerToColumnMap.ts","../../src/sheets/SheetsApi.ts","../../src/sheets/LifsheetsGenerator.ts","../../src/sheets/TJSheetsGenerator.ts","../../src/sheets/UCSDSheetsGenerator.ts","../../src/sheets/Sheets.ts","../../src/components/FilePicker.tsx","../../src/components/PacketLoader.tsx","../../src/components/PlayerEntry.tsx","../../src/components/ManualTeamEntry.tsx","../../src/components/PlayerRoster.tsx","../../src/components/FromRostersTeamEntry.tsx","../../src/components/GameFormatPicker.tsx","../../src/components/dialogs/NewGameDialog.tsx","../../src/components/RoundSelector.tsx","../../src/components/dialogs/ExportToSheetsDialog.tsx","../../src/components/dialogs/ExportToJsonDialog.tsx","../../src/components/dialogs/ImportGameDialog.tsx","../../src/components/dialogs/FontDialogController.ts","../../src/components/dialogs/FontDialog.tsx","../../src/components/dialogs/HelpDialog.tsx","../../src/components/dialogs/CustomGameFormatDialogController.ts","../../src/components/dialogs/CustomizeGameFormatDialog.tsx","../../src/components/dialogs/AddQuestionsDialogController.ts","../../src/components/dialogs/AddQuestionsDialog.tsx","../../src/components/dialogs/MessageDialog.tsx","../../src/components/dialogs/RenamePlayerDialogController.ts","../../src/components/dialogs/RenamePlayerDialog.tsx","../../src/components/dialogs/ReorderPlayersDialogController.ts","../../src/components/dialogs/ReorderPlayerDialog.tsx","../../src/components/dialogs/ScoresheetDialog.tsx","../../src/components/dialogs/RenameTeamDialogController.ts","../../src/components/dialogs/RenameTeamDialog.tsx","../../src/components/ModalDialogContainer.tsx","../../src/components/ModaqControl.tsx","../../src/demo/app.tsx"],"sourcesContent":["const p = function polyfill() {\n const relList = document.createElement('link').relList;\n if (relList && relList.supports && relList.supports('modulepreload')) {\n return;\n }\n for (const link of document.querySelectorAll('link[rel=\"modulepreload\"]')) {\n processPreload(link);\n }\n new MutationObserver((mutations) => {\n for (const mutation of mutations) {\n if (mutation.type !== 'childList') {\n continue;\n }\n for (const node of mutation.addedNodes) {\n if (node.tagName === 'LINK' && node.rel === 'modulepreload')\n processPreload(node);\n }\n }\n }).observe(document, { childList: true, subtree: true });\n function getFetchOpts(script) {\n const fetchOpts = {};\n if (script.integrity)\n fetchOpts.integrity = script.integrity;\n if (script.referrerpolicy)\n fetchOpts.referrerPolicy = script.referrerpolicy;\n if (script.crossorigin === 'use-credentials')\n fetchOpts.credentials = 'include';\n else if (script.crossorigin === 'anonymous')\n fetchOpts.credentials = 'omit';\n else\n fetchOpts.credentials = 'same-origin';\n return fetchOpts;\n }\n function processPreload(link) {\n if (link.ep)\n // ep marker = processed\n return;\n link.ep = true;\n // prepopulate the load record\n const fetchOpts = getFetchOpts(link);\n fetch(link.href, fetchOpts);\n }\n};__VITE_IS_MODERN__&&p();","import { makeAutoObservable } from \"mobx\";\r\n\r\n// TODO: Investigate if Team should just have an array of players, since we don't need to normalize these values in mobx\r\n// This could simplify the format\r\nexport class Player implements IPlayer {\r\n public isStarter: boolean;\r\n\r\n public name: string;\r\n\r\n public teamName: string;\r\n\r\n constructor(name: string, teamName: string, isStarter: boolean) {\r\n makeAutoObservable(this);\r\n\r\n this.name = name;\r\n this.teamName = teamName;\r\n this.isStarter = isStarter;\r\n }\r\n\r\n public setStarterStatus(isStarter: boolean): void {\r\n this.isStarter = isStarter;\r\n }\r\n\r\n public setName(newName: string): void {\r\n this.name = newName;\r\n }\r\n\r\n public setTeamName(teamName: string): void {\r\n this.teamName = teamName;\r\n }\r\n}\r\n\r\nexport interface IPlayer {\r\n name: string;\r\n teamName: string;\r\n isStarter: boolean;\r\n}\r\n","import { IPlayer, Player } from \"./TeamState\";\r\n\r\nexport function playersEqual(player: IPlayer, other: IPlayer): boolean {\r\n return player.name === other.name && player.teamName === other.teamName;\r\n}\r\n\r\nexport function movePlayerBackward(players: Player[], player: Player): Player[] {\r\n // Make a copy so that we don't overwrite the original array\r\n players = [...players];\r\n\r\n let nextTeammateIndex = -1;\r\n for (let i = players.length - 1; i >= 0; i--) {\r\n const currentPlayer: IPlayer = players[i];\r\n if (player === currentPlayer) {\r\n if (nextTeammateIndex === -1) {\r\n // Current player is in front, we can't move them\r\n return players;\r\n }\r\n\r\n players[i] = players[nextTeammateIndex];\r\n players[nextTeammateIndex] = player;\r\n return players;\r\n }\r\n\r\n if (players[i].teamName === player.teamName) {\r\n nextTeammateIndex = i;\r\n }\r\n }\r\n\r\n return players;\r\n}\r\n\r\nexport function movePlayerForward(players: Player[], player: Player): Player[] {\r\n // Make a copy so that we don't overwrite the original array\r\n players = [...players];\r\n\r\n let previousTeammateIndex = -1;\r\n for (let i = 0; i < players.length; i++) {\r\n const currentPlayer: IPlayer = players[i];\r\n if (player === currentPlayer) {\r\n if (previousTeammateIndex === -1) {\r\n // Current player is in front, we can't move them\r\n return players;\r\n }\r\n\r\n players[i] = players[previousTeammateIndex];\r\n players[previousTeammateIndex] = player;\r\n return players;\r\n }\r\n\r\n if (players[i].teamName === player.teamName) {\r\n previousTeammateIndex = i;\r\n }\r\n }\r\n\r\n return players;\r\n}\r\n\r\nexport function movePlayerToIndex(players: Player[], player: Player, index: number): Player[] {\r\n if (index < 0) {\r\n return players;\r\n }\r\n\r\n const teamName: string = player.teamName;\r\n let sameTeamCount = -1;\r\n for (let i = 0; i < players.length; i++) {\r\n const currentPlayer: IPlayer = players[i];\r\n if (currentPlayer.teamName === teamName) {\r\n sameTeamCount++;\r\n }\r\n\r\n if (sameTeamCount === index) {\r\n let newPlayers = players.filter((p) => p !== player);\r\n newPlayers = newPlayers.slice(0, i).concat(player).concat(newPlayers.slice(i));\r\n return newPlayers;\r\n }\r\n }\r\n\r\n // Index is beyond the number of players in the team. Treat it as a no-op\r\n return players;\r\n}\r\n","import { observable, action, computed, makeObservable } from \"mobx\";\r\nimport { format } from \"mobx-sync\";\r\n\r\nimport * as Events from \"./Events\";\r\nimport * as PlayerUtils from \"./PlayerUtils\";\r\nimport { IBuzzMarker } from \"./IBuzzMarker\";\r\nimport { IGameFormat } from \"./IGameFormat\";\r\nimport { IPlayer } from \"./TeamState\";\r\n\r\n// TODO: Build a stack of cycle changes, so we can support undo. This might mean that mobx isn't the best store for it\r\n// (not as simple to go back)\r\n\r\nexport class Cycle implements ICycle {\r\n @format(\r\n (persistedBuzz: Events.ITossupAnswerEvent & { correct: boolean }, currentBuzz: Events.ITossupAnswerEvent) => {\r\n // Old games would have a \"correct\" field instead of having points on the marker\r\n if (persistedBuzz.correct != undefined && persistedBuzz.marker.points == undefined) {\r\n currentBuzz.marker.points = 10;\r\n }\r\n\r\n return currentBuzz;\r\n }\r\n )\r\n correctBuzz?: Events.ITossupAnswerEvent;\r\n\r\n wrongBuzzes?: Events.ITossupAnswerEvent[];\r\n\r\n bonusAnswer?: Events.IBonusAnswerEvent;\r\n\r\n playerJoins?: Events.IPlayerJoinsEvent[];\r\n\r\n playerLeaves?: Events.IPlayerLeavesEvent[];\r\n\r\n subs?: Events.ISubstitutionEvent[];\r\n\r\n timeouts?: Events.ITimeoutEvent[];\r\n\r\n bonusProtests?: Events.IBonusProtestEvent[];\r\n\r\n tossupProtests?: Events.ITossupProtestEvent[];\r\n\r\n thrownOutTossups?: Events.IThrowOutQuestionEvent[];\r\n\r\n thrownOutBonuses?: Events.IThrowOutQuestionEvent[];\r\n\r\n onUpdate?: () => void;\r\n\r\n constructor(deserializedCycle?: ICycle) {\r\n // We don't use makeAutoObservable because there are methods like getProtestableBonusPartIndexes which aren't\r\n // actions\r\n makeObservable(this, {\r\n correctBuzz: observable,\r\n firstWrongBuzz: computed,\r\n wrongBuzzes: observable,\r\n bonusAnswer: observable,\r\n playerJoins: observable,\r\n playerLeaves: observable,\r\n subs: observable,\r\n timeouts: observable,\r\n bonusProtests: observable,\r\n tossupProtests: observable,\r\n thrownOutTossups: observable,\r\n thrownOutBonuses: observable,\r\n orderedBuzzes: computed,\r\n addCorrectBuzz: action,\r\n addWrongBuzz: action,\r\n addBonusProtest: action,\r\n addPlayerJoins: action,\r\n addPlayerLeaves: action,\r\n addSwapSubstitution: action,\r\n addThrownOutBonus: action,\r\n addThrownOutTossup: action,\r\n addTossupProtest: action,\r\n removeBonusProtest: action,\r\n removeCorrectBuzz: action,\r\n removePlayerJoins: action,\r\n removePlayerLeaves: action,\r\n removeSubstitution: action,\r\n removeThrownOutBonus: action,\r\n removeThrownOutTossup: action,\r\n removeTossupProtest: action,\r\n removeWrongBuzz: action,\r\n setBonusPartAnswer: action,\r\n });\r\n\r\n // Using autorun requries checking all the events and could be called multiple times, so just use this method\r\n // directly instead\r\n this.onUpdate = undefined;\r\n\r\n if (deserializedCycle) {\r\n this.bonusAnswer =\r\n deserializedCycle.bonusAnswer == undefined\r\n ? undefined\r\n : Cycle.formatBonusAnswer(deserializedCycle.bonusAnswer);\r\n this.bonusProtests = deserializedCycle.bonusProtests;\r\n this.correctBuzz = deserializedCycle.correctBuzz;\r\n this.wrongBuzzes = deserializedCycle.wrongBuzzes;\r\n this.playerJoins = deserializedCycle.playerJoins;\r\n this.playerLeaves = deserializedCycle.playerLeaves;\r\n this.subs = deserializedCycle.subs;\r\n this.timeouts = deserializedCycle.timeouts;\r\n this.thrownOutBonuses = deserializedCycle.thrownOutBonuses;\r\n this.thrownOutTossups = deserializedCycle.thrownOutTossups;\r\n this.tossupProtests = deserializedCycle.tossupProtests;\r\n\r\n // Back-compat, when we split wrong buzzes based on if they were negs or not\r\n if (deserializedCycle.noPenaltyBuzzes) {\r\n this.wrongBuzzes = (this.wrongBuzzes ?? []).concat(deserializedCycle.noPenaltyBuzzes);\r\n }\r\n\r\n if (deserializedCycle.negBuzz) {\r\n this.wrongBuzzes = (this.wrongBuzzes ?? []).concat(deserializedCycle.negBuzz);\r\n }\r\n }\r\n }\r\n\r\n public get firstWrongBuzz(): Events.ITossupAnswerEvent | undefined {\r\n if (this.wrongBuzzes == undefined || this.wrongBuzzes.length === 0) {\r\n return undefined;\r\n }\r\n\r\n const sortedWrongBuzzes: Events.ITossupAnswerEvent[] = [...this.wrongBuzzes].sort(\r\n (left, right) => left.marker.position - right.marker.position\r\n );\r\n\r\n let negBuzz: Events.ITossupAnswerEvent = sortedWrongBuzzes[0];\r\n if (\r\n sortedWrongBuzzes.length > 1 &&\r\n sortedWrongBuzzes[0].marker.position === sortedWrongBuzzes[1].marker.position\r\n ) {\r\n // The neg buzz should be the fist buzz, so find the first buzz in the list of wrong buzzes\r\n const firstNegBuzz: Events.ITossupAnswerEvent | undefined = this.wrongBuzzes.find(\r\n (event) => event.marker.position === negBuzz.marker.position\r\n );\r\n if (firstNegBuzz == undefined) {\r\n throw new Error(\"Neg couldn't be found in list of incorrect buzzes\");\r\n }\r\n\r\n negBuzz = firstNegBuzz;\r\n }\r\n\r\n return negBuzz;\r\n }\r\n\r\n public get orderedBuzzes(): Events.ITossupAnswerEvent[] {\r\n // Sort by tossupIndex, then by position. Tie breaker: negs before no penalties, negs/no penalties before correct\r\n const buzzes: Events.ITossupAnswerEvent[] = this.wrongBuzzes ? [...this.wrongBuzzes] : [];\r\n\r\n if (this.correctBuzz) {\r\n buzzes.push(this.correctBuzz);\r\n }\r\n\r\n // Prioritization should be\r\n // - Buzzes on earlier tossups\r\n // - Buzzes earlier in the tossup\r\n // - Incorrect buzzes before correct ones\r\n // - Negs before no penalty buzzes\r\n // If we decide to store the point value with the buzz, just compare the point values\r\n buzzes.sort((buzz, otherBuzz) => {\r\n if (buzz.tossupIndex < otherBuzz.tossupIndex) {\r\n return -1;\r\n } else if (buzz.marker.position < otherBuzz.marker.position) {\r\n return -1;\r\n }\r\n\r\n return buzz.tossupIndex < otherBuzz.tossupIndex ||\r\n buzz.marker.position < otherBuzz.marker.position ||\r\n (buzz.marker.points <= 0 && otherBuzz.marker.points > 0) ||\r\n (buzz.marker.points < 0 && otherBuzz.marker.points >= 0)\r\n ? -1\r\n : 1;\r\n });\r\n\r\n return buzzes;\r\n }\r\n\r\n private static formatBonusAnswer(persistedBonusAnswer: Events.IBonusAnswerEvent) {\r\n // Old games only have correctParts. Try to guess how many parts it has (either the standard 3 or something\r\n // based on the highest index)\r\n\r\n // default to 3 or the the length an array would need to be to support the index, whichever is higher\r\n if (persistedBonusAnswer.parts == undefined) {\r\n persistedBonusAnswer.parts = new Array(\r\n Math.max(\r\n 3,\r\n persistedBonusAnswer.correctParts.reduce((old, current) => Math.max(old, current.index), 0) + 1\r\n )\r\n );\r\n\r\n for (let i = 0; i < persistedBonusAnswer.parts.length; i++) {\r\n persistedBonusAnswer.parts[i] = { points: 0, teamName: \"\" };\r\n }\r\n\r\n for (const part of persistedBonusAnswer.correctParts) {\r\n const currentPart: Events.IBonusAnswerPart = persistedBonusAnswer.parts[part.index];\r\n currentPart.points = part.points;\r\n currentPart.teamName = persistedBonusAnswer.receivingTeamName;\r\n }\r\n }\r\n\r\n return persistedBonusAnswer;\r\n }\r\n\r\n public addCorrectBuzz(\r\n marker: IBuzzMarker,\r\n tossupIndex: number,\r\n gameFormat: IGameFormat,\r\n bonusIndex: number | undefined,\r\n partsCount: number | undefined\r\n ): void {\r\n this.removeTeamsBuzzes(marker.player.teamName, tossupIndex);\r\n\r\n this.correctBuzz = {\r\n tossupIndex,\r\n marker,\r\n };\r\n\r\n // TODO: we need a method to set the bonus index (either passed in here or with a new method)\r\n // Alternatively, we can remove the tossupIndex/bonusIndex, and fill it in whenever we have to serialize this\r\n if (\r\n bonusIndex != undefined &&\r\n (this.bonusAnswer == undefined || marker.player.teamName !== this.bonusAnswer.receivingTeamName)\r\n ) {\r\n const parts: Events.IBonusAnswerPart[] = [];\r\n if (partsCount !== undefined) {\r\n for (let i = 0; i < partsCount; i++) {\r\n parts.push({ teamName: \"\", points: 0 });\r\n }\r\n }\r\n\r\n this.bonusAnswer = {\r\n bonusIndex,\r\n correctParts: [],\r\n receivingTeamName: marker.player.teamName,\r\n parts,\r\n };\r\n }\r\n\r\n // We should also remove all buzzes after this one, since a correct buzz should be the last one.\r\n if (this.wrongBuzzes) {\r\n this.wrongBuzzes = this.wrongBuzzes.filter((buzz) => buzz.marker.position <= marker.position);\r\n this.updateNeg(gameFormat);\r\n }\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public addWrongBuzz(marker: IBuzzMarker, tossupIndex: number, gameFormat: IGameFormat): void {\r\n if (this.wrongBuzzes == undefined) {\r\n this.wrongBuzzes = [];\r\n }\r\n\r\n this.removeTeamsBuzzes(marker.player.teamName, tossupIndex);\r\n\r\n const event: Events.ITossupAnswerEvent = {\r\n marker,\r\n tossupIndex,\r\n };\r\n\r\n const buzzIndex: number = this.wrongBuzzes.findIndex((buzz) => buzz.marker.position > marker.position);\r\n if (buzzIndex === -1) {\r\n this.wrongBuzzes.push(event);\r\n } else {\r\n const laterBuzzes: Events.ITossupAnswerEvent[] = this.wrongBuzzes.splice(buzzIndex);\r\n this.wrongBuzzes.push(event);\r\n this.wrongBuzzes = this.wrongBuzzes.concat(laterBuzzes);\r\n\r\n this.updateNeg(gameFormat);\r\n }\r\n\r\n // Clear the correct buzz if it's before this one, since the correct buzz should be the last one\r\n if (this.correctBuzz && this.correctBuzz.marker.position < marker.position) {\r\n this.removeCorrectBuzz();\r\n }\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public addBonusProtest(\r\n questionIndex: number,\r\n partIndex: number,\r\n givenAnswer: string | undefined,\r\n reason: string,\r\n teamName: string\r\n ): void {\r\n if (this.correctBuzz == undefined) {\r\n // There's no correct buzz, so there's no one to protest the bonus\r\n return;\r\n }\r\n\r\n if (this.bonusProtests == undefined) {\r\n this.bonusProtests = [];\r\n }\r\n\r\n // TODO: Investigate if we can get the questionIndex from the bonusAnswer event\r\n this.bonusProtests.push({\r\n givenAnswer,\r\n reason,\r\n partIndex,\r\n questionIndex,\r\n teamName,\r\n });\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public addPlayerJoins(inPlayer: IPlayer): void {\r\n if (this.playerJoins == undefined) {\r\n this.playerJoins = [];\r\n }\r\n\r\n this.playerJoins.push({\r\n inPlayer,\r\n });\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public addPlayerLeaves(outPlayer: IPlayer): void {\r\n // If the player just joined this cycle, remove them from the list of added players and remove their buzzes.\r\n // There's no need to keep them in both lists.\r\n if (this.playerJoins != undefined) {\r\n const joinEvent: Events.IPlayerJoinsEvent | undefined = this.playerJoins.find((event) =>\r\n PlayerUtils.playersEqual(event.inPlayer, outPlayer)\r\n );\r\n if (joinEvent) {\r\n this.playerJoins = this.playerJoins.filter((event) => event !== joinEvent);\r\n this.removePlayerBuzzes(outPlayer);\r\n return;\r\n }\r\n }\r\n\r\n if (this.playerLeaves == undefined) {\r\n this.playerLeaves = [];\r\n }\r\n\r\n this.playerLeaves.push({\r\n outPlayer,\r\n });\r\n\r\n this.removePlayerBuzzes(outPlayer);\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public addSwapSubstitution(inPlayer: IPlayer, outPlayer: IPlayer): void {\r\n if (this.subs == undefined) {\r\n this.subs = [];\r\n }\r\n\r\n this.subs.push({\r\n inPlayer,\r\n outPlayer,\r\n });\r\n\r\n this.removePlayerBuzzes(outPlayer);\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public addThrownOutBonus(bonusIndex: number): void {\r\n if (this.thrownOutBonuses == undefined) {\r\n this.thrownOutBonuses = [];\r\n }\r\n\r\n this.thrownOutBonuses.push({\r\n questionIndex: bonusIndex,\r\n });\r\n\r\n // Clear the bonus answer event\r\n if (this.bonusAnswer != undefined) {\r\n this.resetBonusAnswer();\r\n this.bonusAnswer.bonusIndex = bonusIndex + 1;\r\n }\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public addThrownOutTossup(tossupIndex: number): void {\r\n if (this.thrownOutTossups == undefined) {\r\n this.thrownOutTossups = [];\r\n }\r\n\r\n this.thrownOutTossups.push({\r\n questionIndex: tossupIndex,\r\n });\r\n\r\n // If we threw out the tossup, then we can't have a correct buzz; otherwise the tossup would've been allowed to\r\n // stay. Clear the correct buzz\r\n this.removeCorrectBuzz();\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public addTossupProtest(\r\n teamName: string,\r\n questionIndex: number,\r\n position: number,\r\n givenAnswer: string | undefined,\r\n reason: string\r\n ): void {\r\n if (this.tossupProtests == undefined) {\r\n this.tossupProtests = [];\r\n }\r\n\r\n this.tossupProtests.push({\r\n givenAnswer,\r\n reason,\r\n position,\r\n questionIndex,\r\n teamName,\r\n });\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n // TODO: Try to make this a computed function\r\n public getProtestableBonusPartIndexes(bonusPartsCount: number): number[] {\r\n const indexes: number[] = [];\r\n\r\n const protestedIndexes: number[] = this.bonusProtests?.map((protest) => protest.partIndex) ?? [];\r\n const protestedIndexesSet = new Set(protestedIndexes);\r\n\r\n for (let i = 0; i < bonusPartsCount; i++) {\r\n if (!protestedIndexesSet.has(i)) {\r\n indexes.push(i);\r\n }\r\n }\r\n\r\n return indexes;\r\n }\r\n\r\n public removeBonusProtest(partIndex: number): void {\r\n if (this.bonusProtests == undefined) {\r\n return;\r\n }\r\n\r\n this.bonusProtests = this.bonusProtests.filter((protest) => protest.partIndex !== partIndex);\r\n if (this.bonusProtests.length === 0) {\r\n this.bonusProtests = undefined;\r\n }\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public removeCorrectBuzz(): void {\r\n this.correctBuzz = undefined;\r\n this.bonusAnswer = undefined;\r\n this.bonusProtests = undefined;\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public removeNewPlayerEvents(removedPlayer: IPlayer): void {\r\n if (this.playerJoins) {\r\n // Remove their joins\r\n let playerJoinsToRemove: Events.IPlayerJoinsEvent | undefined;\r\n for (const playerJoins of this.playerJoins) {\r\n if (playerJoins.inPlayer === removedPlayer) {\r\n playerJoinsToRemove = playerJoins;\r\n break;\r\n }\r\n }\r\n\r\n if (playerJoinsToRemove) {\r\n this.removePlayerJoins(playerJoinsToRemove);\r\n }\r\n }\r\n\r\n if (this.playerLeaves) {\r\n // Remove their leaves (no longer leaves, since they were never added)\r\n let playerLeavesToRemove: Events.IPlayerLeavesEvent | undefined;\r\n for (const playerLeaves of this.playerLeaves) {\r\n if (playerLeaves.outPlayer === removedPlayer) {\r\n playerLeavesToRemove = playerLeaves;\r\n break;\r\n }\r\n }\r\n\r\n if (playerLeavesToRemove) {\r\n this.removePlayerLeaves(playerLeavesToRemove);\r\n }\r\n }\r\n\r\n if (this.subs) {\r\n // Remove their substitutions\r\n let subToRemove: Events.ISubstitutionEvent | undefined;\r\n for (const sub of this.subs) {\r\n if (sub.inPlayer === removedPlayer || sub.outPlayer === removedPlayer) {\r\n subToRemove = sub;\r\n break;\r\n }\r\n }\r\n\r\n if (subToRemove) {\r\n this.removeSubstitution(subToRemove);\r\n }\r\n }\r\n\r\n this.removePlayerBuzzes(removedPlayer);\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public removePlayerJoins(joinToRemove: Events.IPlayerJoinsEvent): void {\r\n if (this.playerJoins == undefined) {\r\n return;\r\n }\r\n\r\n this.playerJoins = this.playerJoins.filter((join) => join !== joinToRemove);\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public removePlayerLeaves(leaveToRemove: Events.IPlayerLeavesEvent): void {\r\n if (this.playerLeaves == undefined) {\r\n return;\r\n }\r\n\r\n this.playerLeaves = this.playerLeaves.filter((leave) => leave !== leaveToRemove);\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public removeSubstitution(subToRemove: Events.ISubstitutionEvent): void {\r\n if (this.subs == undefined) {\r\n return;\r\n }\r\n\r\n this.subs = this.subs.filter((sub) => sub !== subToRemove);\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public removeThrownOutBonus(bonusIndex: number): void {\r\n if (this.thrownOutBonuses == undefined) {\r\n return;\r\n }\r\n\r\n this.thrownOutBonuses = this.thrownOutBonuses.filter(\r\n (thrownOutTossup) => thrownOutTossup.questionIndex !== bonusIndex\r\n );\r\n if (this.thrownOutBonuses.length === 0) {\r\n this.thrownOutBonuses = undefined;\r\n }\r\n\r\n // Go back to the old bonus\r\n if (this.bonusAnswer != undefined) {\r\n this.resetBonusAnswer();\r\n this.bonusAnswer.bonusIndex = bonusIndex - 1;\r\n }\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public removeThrownOutTossup(tossupIndex: number): void {\r\n if (this.thrownOutTossups == undefined) {\r\n return;\r\n }\r\n\r\n this.thrownOutTossups = this.thrownOutTossups.filter(\r\n (thrownOutTossup) => thrownOutTossup.questionIndex !== tossupIndex\r\n );\r\n if (this.thrownOutTossups.length === 0) {\r\n this.thrownOutTossups = undefined;\r\n }\r\n\r\n // If the latest tossup is no longer thrown out, then the correct buzz doesn't apply anymore\r\n this.removeCorrectBuzz();\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public removeTossupProtest(teamName: string): void {\r\n if (this.tossupProtests == undefined) {\r\n return;\r\n }\r\n\r\n this.tossupProtests = this.tossupProtests.filter((protest) => protest.teamName !== teamName);\r\n if (this.tossupProtests.length === 0) {\r\n this.tossupProtests = undefined;\r\n }\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public removeWrongBuzz(player: IPlayer, gameFormat: IGameFormat): void {\r\n if (this.wrongBuzzes == undefined) {\r\n return;\r\n }\r\n\r\n const oldLength: number = this.wrongBuzzes.length;\r\n this.wrongBuzzes = this.wrongBuzzes.filter((buzz) => !PlayerUtils.playersEqual(buzz.marker.player, player));\r\n if (this.wrongBuzzes.length === oldLength) {\r\n // Nothing left, don't make any changes\r\n return;\r\n }\r\n\r\n this.updateNeg(gameFormat);\r\n this.removeTossupProtest(player.teamName);\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public setBonusPartAnswer(index: number, teamName: string, points: number): void {\r\n if (this.bonusAnswer == undefined) {\r\n // Nothing to set, since there's no part\r\n return;\r\n } else if (this.bonusAnswer.parts == undefined) {\r\n // default to 3 or the the length an array would need to be to support the index, whichever is higher\r\n this.bonusAnswer.parts = new Array(Math.max(3, index + 1));\r\n }\r\n\r\n this.bonusAnswer.parts[index] = { teamName, points };\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n public setUpdateHandler(handler: () => void | undefined): void {\r\n this.onUpdate = handler;\r\n }\r\n\r\n private resetBonusAnswer(): void {\r\n if (this.bonusAnswer != undefined) {\r\n const parts: Events.IBonusAnswerPart[] = this.bonusAnswer.parts ?? new Array(3);\r\n for (let i = 0; i < parts.length; i++) {\r\n parts[i] = { teamName: \"\", points: 0 };\r\n }\r\n\r\n this.bonusAnswer = {\r\n bonusIndex: this.bonusAnswer.bonusIndex,\r\n correctParts: [],\r\n receivingTeamName: this.bonusAnswer.receivingTeamName,\r\n parts,\r\n };\r\n }\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n private removePlayerBuzzes(player: IPlayer): void {\r\n this.removeBuzzes((event) => PlayerUtils.playersEqual(player, event.marker.player));\r\n\r\n this.tossupProtests = this.tossupProtests?.filter((protest) => protest.teamName !== player.teamName);\r\n\r\n this.updateIfNeeded();\r\n }\r\n\r\n private removeTeamsBuzzes(teamName: string, tossupIndex: number): void {\r\n this.removeBuzzes((event) => event.tossupIndex === tossupIndex && event.marker.player.teamName === teamName);\r\n\r\n // TODO: Clear the (tossup) protests from this team. Figure out if we need to remove bonus protests too.\r\n this.tossupProtests = this.tossupProtests?.filter((protest) => protest.teamName !== teamName);\r\n }\r\n\r\n private removeBuzzes(filter: (event: Events.ITossupAnswerEvent) => boolean): void {\r\n if (this.correctBuzz && filter(this.correctBuzz)) {\r\n this.removeCorrectBuzz();\r\n } else if (this.wrongBuzzes && this.wrongBuzzes.findIndex((buzz) => filter(buzz)) >= 0) {\r\n this.wrongBuzzes = this.wrongBuzzes.filter((buzz) => !filter(buzz));\r\n }\r\n }\r\n\r\n private updateIfNeeded(): void {\r\n if (this.onUpdate) {\r\n this.onUpdate();\r\n }\r\n }\r\n\r\n // If we changed the order of wrongBuzzes, it may be that the first wrong buzz is different, so we need to\r\n // reset the point values for the wrong ubzzes\r\n private updateNeg(gameFormat: IGameFormat): void {\r\n if (\r\n this.wrongBuzzes != undefined &&\r\n this.wrongBuzzes.length > 0 &&\r\n this.wrongBuzzes[0].marker.isLastWord === false\r\n ) {\r\n this.wrongBuzzes[0].marker.points = gameFormat.negValue;\r\n for (let i = 1; i < this.wrongBuzzes.length; i++) {\r\n this.wrongBuzzes[i].marker.points = 0;\r\n }\r\n }\r\n }\r\n}\r\n\r\nexport interface ICycle {\r\n correctBuzz?: Events.ITossupAnswerEvent;\r\n wrongBuzzes?: Events.ITossupAnswerEvent[];\r\n bonusAnswer?: Events.IBonusAnswerEvent;\r\n playerJoins?: Events.IPlayerJoinsEvent[];\r\n playerLeaves?: Events.IPlayerLeavesEvent[];\r\n subs?: Events.ISubstitutionEvent[];\r\n timeouts?: Events.ITimeoutEvent[];\r\n bonusProtests?: Events.IBonusProtestEvent[];\r\n tossupProtests?: Events.ITossupProtestEvent[];\r\n thrownOutTossups?: Events.IThrowOutQuestionEvent[];\r\n thrownOutBonuses?: Events.IThrowOutQuestionEvent[];\r\n\r\n // Obsolete; remove after a few release versions\r\n noPenaltyBuzzes?: Events.ITossupAnswerEvent[];\r\n negBuzz?: Events.ITossupAnswerEvent;\r\n}\r\n","import { IGameFormat, IPowerMarker } from \"./IGameFormat\";\r\n\r\n// We can't rely on a currentVersion we fill in, so these have to be manually tracked if there are breaking changes\r\n\r\nconst currentVersion = \"2024-03-20\";\r\n\r\nexport const ACFGameFormat: IGameFormat = {\r\n bonusesBounceBack: false,\r\n displayName: \"ACF\",\r\n minimumOvertimeQuestionCount: 1,\r\n overtimeIncludesBonuses: false,\r\n negValue: -5,\r\n powers: [],\r\n regulationTossupCount: 20,\r\n timeoutsAllowed: 1,\r\n pronunciationGuideMarkers: ['(\"', '\")'],\r\n pairTossupsBonuses: false,\r\n version: currentVersion,\r\n};\r\n\r\nexport const PACEGameFormat: IGameFormat = {\r\n bonusesBounceBack: false,\r\n displayName: \"PACE\",\r\n minimumOvertimeQuestionCount: 1,\r\n overtimeIncludesBonuses: false,\r\n negValue: 0,\r\n powers: [{ marker: \"(*)\", points: 20 }],\r\n regulationTossupCount: 20,\r\n timeoutsAllowed: 1,\r\n pronunciationGuideMarkers: ['(\"', '\")'],\r\n pairTossupsBonuses: false,\r\n version: currentVersion,\r\n};\r\n\r\nexport const StandardPowersMACFGameFormat: IGameFormat = {\r\n ...createMACFGameFormat([{ marker: \"(*)\", points: 15 }]),\r\n displayName: \"mACF with powers\",\r\n};\r\n\r\nexport const UndefinedGameFormat: IGameFormat = {\r\n bonusesBounceBack: false,\r\n displayName: \"Freeform format\",\r\n minimumOvertimeQuestionCount: 1,\r\n overtimeIncludesBonuses: false,\r\n negValue: -5,\r\n powers: [{ marker: \"(*)\", points: 15 }],\r\n regulationTossupCount: 999,\r\n timeoutsAllowed: 999,\r\n pronunciationGuideMarkers: ['(\"', '\")'],\r\n pairTossupsBonuses: false,\r\n version: currentVersion,\r\n};\r\n\r\nexport function getKnownFormats(): IGameFormat[] {\r\n return [ACFGameFormat, StandardPowersMACFGameFormat, PACEGameFormat, UndefinedGameFormat];\r\n}\r\n\r\nexport function createMACFGameFormat(powers: IPowerMarker[]): IGameFormat {\r\n return {\r\n ...ACFGameFormat,\r\n powers,\r\n };\r\n}\r\n\r\nexport function getUpgradedFormatVersion(format: IGameFormat): IGameFormat {\r\n if (format.version === currentVersion) {\r\n return format;\r\n }\r\n\r\n updatePowerMarkers(format);\r\n\r\n // We need to compare the fields between the given format and the current format, so we need to iterate over them.\r\n // This requires using the array/dictionary syntax for accessing fields, which requires using any.\r\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\r\n const defaultFormat: any = UndefinedGameFormat as any;\r\n\r\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\r\n const formatObject: any = format as any;\r\n for (const key of Object.keys(defaultFormat)) {\r\n if (key === \"displayName\" || key === \"pronunciationGuideMarkers\") {\r\n continue;\r\n }\r\n\r\n if (formatObject[key] == undefined) {\r\n throwInvalidGameFormatError(\r\n `Game format uses an incompatible version (${format.version}). Unknown setting \"${key}\".`\r\n );\r\n } else if (typeof formatObject[key] !== typeof defaultFormat[key]) {\r\n throwInvalidGameFormatError(\r\n `Game format uses an incompatible version (${format.version}). \"${key}\" is an incompatible type.`\r\n );\r\n }\r\n }\r\n\r\n return format;\r\n}\r\n\r\nfunction updatePowerMarkers(gameFormat: IGameFormat): void {\r\n if (\r\n gameFormat.powers != undefined ||\r\n gameFormat.pointsForPowers == undefined ||\r\n gameFormat.powerMarkers == undefined\r\n ) {\r\n return;\r\n }\r\n\r\n if (gameFormat.powerMarkers.length < gameFormat.pointsForPowers.length) {\r\n throwInvalidGameFormatError(\"Game format is invalid. Some power markers don't have point values.\");\r\n }\r\n\r\n gameFormat.powers = [];\r\n for (let i = 0; i < gameFormat.powerMarkers.length; i++) {\r\n gameFormat.powers.push({\r\n marker: gameFormat.powerMarkers[i],\r\n points: gameFormat.pointsForPowers[i],\r\n });\r\n }\r\n}\r\n\r\nfunction throwInvalidGameFormatError(message: string): void {\r\n throw new Error(`${message}. Export your game and see if you can update your format manually, or reset your game`);\r\n}\r\n","import { IFormattedText } from \"./IFormattedText\";\r\n\r\n/**\r\n * Default pronunciation guide markers used if none are passed into `IFormattingOptions`\r\n */\r\nexport const defaultPronunciationGuideMarkers: [string, string] = [\"(\", \")\"];\r\n\r\n/**\r\n * Default reader directives used if none are passed into `IFormattingOptions`\r\n */\r\nexport const defaultReaderDirectives: string[] = [\r\n \"(emphasize)\",\r\n \"(pause)\",\r\n \"(read slowly)\",\r\n \"[emphasize]\",\r\n \"[pause]\",\r\n \"[read slowly]\",\r\n];\r\n\r\n/**\r\n * Options for how to parse and format text\r\n */\r\nexport interface IFormattingOptions {\r\n /**\r\n * Two-element array where the first string is the tag for the start of a pronunciation guide and the second string\r\n * is the tag for the end. For example, if the pronuncation guide looks like \"(guide)\", the array would be\r\n * [ \"(\", \")\" ]. Pronunciation guides don't count as words and are formatted differently from the rest of the\r\n * question text.\r\n * If no value is provided, then `defaultPronunciationGuideMarkers` will be used.\r\n */\r\n pronunciationGuideMarkers?: [string, string];\r\n\r\n /**\r\n * Directives for the reader, like \"(read slowly)\". These don't count as words and are formatted differently from\r\n * the rest of the question text.\r\n * If no value is provided, then `defaultReaderDirectives` will be used.\r\n */\r\n readerDirectives?: string[];\r\n}\r\n\r\n/**\r\n * Takes text with formatting tags and turns it into an array of texts with formatting information included, such as\r\n * which words are bolded.\r\n * Note that if the '\"' character is used in a pronunciation guide, it will also support '“' and '”', and vice versa.\r\n * @param text The text to format, such a question or answerline.\r\n * @param options Formtating options, such as what indicates the start of a pronunciation guide.\r\n * @returns An array of `IFormattedText` that represents the text with formatting metadata, such as which words are\r\n * bolded, underlined, etc.\r\n */\r\nexport function parseFormattedText(text: string, options?: IFormattingOptions): IFormattedText[] {\r\n const result: IFormattedText[] = [];\r\n\r\n if (text == undefined) {\r\n return result;\r\n }\r\n\r\n options = options ?? {};\r\n const pronunciationGuideMarkers: [[string, string]] = [\r\n options.pronunciationGuideMarkers ?? defaultPronunciationGuideMarkers,\r\n ];\r\n\r\n // Normalize quotes in pronunciation guides\r\n if (pronunciationGuideMarkers[0][0].includes('\"') || pronunciationGuideMarkers[0][1].includes('\"')) {\r\n pronunciationGuideMarkers.push([\r\n pronunciationGuideMarkers[0][0].replace(/\"/g, \"“\"),\r\n pronunciationGuideMarkers[0][1].replace(/\"/g, \"”\"),\r\n ]);\r\n }\r\n\r\n if (pronunciationGuideMarkers[0][0].includes(\"“\") || pronunciationGuideMarkers[0][1].includes(\"”\")) {\r\n pronunciationGuideMarkers.push([\r\n pronunciationGuideMarkers[0][0].replace(/“/g, '\"'),\r\n pronunciationGuideMarkers[0][1].replace(/”/g, '\"'),\r\n ]);\r\n }\r\n\r\n const readerDirectives: string[] | undefined = options.readerDirectives ?? defaultReaderDirectives;\r\n\r\n let bolded = false;\r\n let emphasized = false;\r\n let underlined = false;\r\n let subscripted = false;\r\n let superscripted = false;\r\n let pronunciation = false;\r\n let startIndex = 0;\r\n\r\n let extraTags = \"\";\r\n for (const pronunciationGuideMarker of pronunciationGuideMarkers) {\r\n extraTags += `|${escapeRegExp(pronunciationGuideMarker[0])}|${escapeRegExp(pronunciationGuideMarker[1])}`;\r\n }\r\n\r\n if (readerDirectives) {\r\n extraTags += `|${readerDirectives.map((directive) => escapeRegExp(directive)).join(\"|\")}`;\r\n }\r\n\r\n // If we need to support older browswers, use RegExp, exec, and a while loop. See\r\n // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/matchAll\r\n const matchIterator: IterableIterator = text.matchAll(\r\n new RegExp(`<\\\\/?em>|<\\\\/?req>|<\\\\/?b>|<\\\\/?u>|<\\\\/?sub>|<\\\\/?sup>${extraTags}`, \"gi\")\r\n );\r\n\r\n for (const match of matchIterator) {\r\n // For the end of the pronunciation guide, we want to include it in the string, so add it to the current slice\r\n // TODO: Do we need to do this with reader directives?\r\n const tag: string = match[0];\r\n const normalizedTag: string = tag.toLowerCase();\r\n let tagInTextLength = 0;\r\n for (const pronunciationGuideMarker of pronunciationGuideMarkers) {\r\n if (normalizedTag === pronunciationGuideMarker[1].toLowerCase()) {\r\n tagInTextLength = pronunciationGuideMarker[1].length;\r\n break;\r\n }\r\n }\r\n\r\n const matchIndex: number = match.index ?? 0;\r\n\r\n const slice: string = text.substring(startIndex, matchIndex + tagInTextLength);\r\n if (slice.length > 0) {\r\n const formattedSlice: IFormattedText = {\r\n text: text.substring(startIndex, matchIndex + tagInTextLength),\r\n bolded,\r\n emphasized,\r\n underlined,\r\n subscripted,\r\n superscripted,\r\n pronunciation,\r\n };\r\n result.push(formattedSlice);\r\n }\r\n\r\n // Once we got the slice of text, toggle the attribute for the next slice\r\n let skipTag = true;\r\n switch (normalizedTag) {\r\n case \"\":\r\n emphasized = true;\r\n break;\r\n case \"\":\r\n emphasized = false;\r\n break;\r\n case \"\":\r\n bolded = true;\r\n underlined = true;\r\n break;\r\n case \"\":\r\n bolded = false;\r\n underlined = false;\r\n break;\r\n case \"\":\r\n bolded = true;\r\n break;\r\n case \"\":\r\n bolded = false;\r\n break;\r\n case \"\":\r\n underlined = true;\r\n break;\r\n case \"\":\r\n underlined = false;\r\n break;\r\n case \"\":\r\n subscripted = true;\r\n break;\r\n case \"\":\r\n subscripted = false;\r\n break;\r\n case \"\":\r\n superscripted = true;\r\n break;\r\n case \"\":\r\n superscripted = false;\r\n break;\r\n default:\r\n let pronunciationGuideMatched = false;\r\n for (const pronunciationGuideMarker of pronunciationGuideMarkers) {\r\n if (normalizedTag === pronunciationGuideMarker[0].toLowerCase()) {\r\n skipTag = false;\r\n pronunciation = true;\r\n pronunciationGuideMatched = true;\r\n } else if (normalizedTag === pronunciationGuideMarker[1].toLowerCase()) {\r\n pronunciation = false;\r\n pronunciationGuideMatched = true;\r\n }\r\n }\r\n\r\n if (pronunciationGuideMatched) {\r\n break;\r\n }\r\n\r\n if (readerDirectives.some((directive) => directive.trim().toLowerCase() === normalizedTag)) {\r\n // Treat it like a pronunciation guide for this one specific word\r\n const readerDirectiveText: IFormattedText = {\r\n text: tag,\r\n bolded,\r\n emphasized,\r\n underlined,\r\n subscripted,\r\n superscripted,\r\n pronunciation: true,\r\n };\r\n result.push(readerDirectiveText);\r\n break;\r\n }\r\n\r\n throw `Unknown match: ${tag}`;\r\n }\r\n\r\n // Skip the tag, since we don't want it in the text. In some cases we want it (start of pronunciation guide), so\r\n // don't skip it in those cases.\r\n if (skipTag) {\r\n startIndex = matchIndex + tag.length;\r\n } else {\r\n startIndex = matchIndex;\r\n }\r\n }\r\n\r\n if (startIndex < text.length) {\r\n result.push({\r\n text: text.substring(startIndex),\r\n bolded,\r\n emphasized,\r\n underlined,\r\n subscripted,\r\n superscripted,\r\n pronunciation,\r\n });\r\n }\r\n\r\n return result;\r\n}\r\n\r\n// TODO: Look into removing the dependency with parseFormattedText, so that we only do one pass over the string instead\r\n// of two passes.\r\n/**\r\n * Takes text with formatting tags and splits it into an array of words with formatting information for each word.\r\n * @param text The text to format, such a question or answerline.\r\n * @param options Formtating options, such as what indicates the start of a pronunciation guide.\r\n * @returns An array of words represented as an `IFormattedText[]` representing all the formatting in that word.\r\n */\r\nexport function splitFormattedTextIntoWords(text: string, options?: IFormattingOptions): IFormattedText[][] {\r\n // We need to take the list of formatted text and split them up into individual words.\r\n // Algorithm: For each piece of formatted text, go through and split the text by the spaces in it.\r\n // If there are no spaces, then add it to a variable tracking the last word.\r\n // If there are spaces, add the last word to the list, and then add each non-empty segment (i.e. non-space) to the\r\n // list, except for the last one. If the last segment isn't empty, set that as the \"last word\", and continue going\r\n // through the list of formatted texts.\r\n const formattedText: IFormattedText[] = parseFormattedText(text, options);\r\n\r\n const splitFormattedText: IFormattedText[][] = [];\r\n\r\n let previousWord: IFormattedText[] = [];\r\n for (const value of formattedText) {\r\n // If we need to worry about mulitiline, we can use /\\s/mg instead\r\n const words: string[] = value.text.split(/\\s+/g);\r\n if (words.length === 1) {\r\n // No spaces in this span. This value to last.\r\n previousWord.push(value);\r\n continue;\r\n }\r\n\r\n // There's a space in this formatted text, so find it, and combine the segments before it into the previous word\r\n // and place the previous word into the list.\r\n const firstWord = words[0];\r\n if (firstWord.length === 0) {\r\n splitFormattedText.push(previousWord);\r\n } else {\r\n previousWord.push({\r\n text: firstWord,\r\n bolded: value.bolded,\r\n emphasized: value.emphasized,\r\n underlined: value.underlined,\r\n subscripted: value.subscripted,\r\n superscripted: value.superscripted,\r\n pronunciation: value.pronunciation,\r\n });\r\n splitFormattedText.push(previousWord);\r\n }\r\n\r\n previousWord = [];\r\n\r\n const lastSegmentIndex: number = words.length - 1;\r\n for (let i = 1; i < lastSegmentIndex; i++) {\r\n const word = words[i];\r\n // Skip spaces. We'll add a space back in later.\r\n if (word.length > 0) {\r\n const formattedWord: IFormattedText = {\r\n text: word,\r\n bolded: value.bolded,\r\n emphasized: value.emphasized,\r\n underlined: value.underlined,\r\n subscripted: value.subscripted,\r\n superscripted: value.superscripted,\r\n pronunciation: value.pronunciation,\r\n };\r\n splitFormattedText.push([formattedWord]);\r\n }\r\n }\r\n\r\n const lastSegment: string = words[lastSegmentIndex];\r\n if (lastSegment.length > 0) {\r\n previousWord.push({\r\n text: lastSegment,\r\n bolded: value.bolded,\r\n emphasized: value.emphasized,\r\n underlined: value.underlined,\r\n subscripted: value.subscripted,\r\n superscripted: value.superscripted,\r\n pronunciation: value.pronunciation,\r\n });\r\n }\r\n }\r\n\r\n // There are no more segments, so this is the last word.\r\n if (previousWord.length > 0) {\r\n splitFormattedText.push(previousWord);\r\n }\r\n\r\n return splitFormattedText;\r\n}\r\n\r\n// Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping\r\nfunction escapeRegExp(text: string) {\r\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"); // $& means the whole matched string\r\n}\r\n","import { makeAutoObservable } from \"mobx\";\r\nimport { format } from \"mobx-sync\";\r\n\r\nimport * as FormattedTextParser from \"../parser/FormattedTextParser\";\r\nimport { IFormattedText } from \"../parser/IFormattedText\";\r\nimport { IGameFormat } from \"./IGameFormat\";\r\n\r\nexport class PacketState {\r\n // Anything with methods/computeds not at the top level needs to use @format to deserialize correctly\r\n @format((deserializedArray: IQuestion[]) => {\r\n return deserializedArray.map((deserializedTossup) => {\r\n return new Tossup(deserializedTossup.question, deserializedTossup.answer, deserializedTossup.metadata);\r\n });\r\n })\r\n public tossups: Tossup[];\r\n\r\n public bonuses: Bonus[];\r\n\r\n constructor() {\r\n makeAutoObservable(this);\r\n\r\n this.tossups = [];\r\n this.bonuses = [];\r\n }\r\n\r\n public setTossups(tossups: Tossup[]): void {\r\n this.tossups = tossups;\r\n }\r\n\r\n public setBonuses(bonuses: Bonus[]): void {\r\n this.bonuses = bonuses;\r\n }\r\n}\r\n\r\nexport interface IQuestion {\r\n question: string;\r\n answer: string;\r\n metadata?: string;\r\n}\r\n\r\nexport interface BonusPart {\r\n question: string;\r\n answer: string;\r\n value: number;\r\n difficultyModifier?: string;\r\n}\r\n\r\nexport class Tossup implements IQuestion {\r\n private static readonly noPronunciationGuides: [string | undefined, string | undefined] = [undefined, undefined];\r\n\r\n public question: string;\r\n public answer: string;\r\n public metadata: string | undefined;\r\n\r\n constructor(question: string, answer: string, metadata?: string) {\r\n makeAutoObservable(this);\r\n\r\n this.question = question;\r\n this.answer = answer;\r\n this.metadata = metadata;\r\n }\r\n\r\n public getPointsAtPosition(format: IGameFormat, wordIndex: number, isCorrect = true): number {\r\n // If there's no powers, default to 10 points\r\n if (format.powers.length === 0 && isCorrect) {\r\n return 10;\r\n }\r\n\r\n const tossupWords: ITossupWord[] = this.getWords(format);\r\n const words: string[] = tossupWords.map((questionText) =>\r\n questionText.word.reduce((result, text) => result + text.text, \"\").trim()\r\n );\r\n // Ignore the last word, which is an end of question marker\r\n const lastIndex: number = words.length - 1;\r\n\r\n let powerMarkerIndex = 0;\r\n for (let i = 0; i < format.powers.length; i++) {\r\n const powerMarker: string = format.powers[i].marker.trim();\r\n const currentPowerMarkerIndex = words.findIndex(\r\n (value, index) => index >= powerMarkerIndex && value.startsWith(powerMarker)\r\n );\r\n if (currentPowerMarkerIndex === -1) {\r\n continue;\r\n }\r\n\r\n powerMarkerIndex = currentPowerMarkerIndex;\r\n const powerMarkerWord: ITossupWord = tossupWords[powerMarkerIndex];\r\n\r\n // To get the word index, we need to subtract the count of non-words from the text index. We only have the\r\n // non-word index, so we have to add 1 to that to get the count (TI - (NWI + 1)). If we use < rather than\r\n // <=, we can remove the -1.\r\n if (\r\n isCorrect &&\r\n powerMarkerIndex !== lastIndex &&\r\n !powerMarkerWord.canBuzzOn &&\r\n wordIndex < powerMarkerWord.textIndex - powerMarkerWord.nonWordIndex\r\n ) {\r\n return format.powers[i].points;\r\n }\r\n }\r\n\r\n if (!isCorrect) {\r\n // If we're at the end of the question, don't count it as a neg\r\n // We add an extra word for the end of question marker, so remove that from the list of words, as well as all of\r\n // the power markers we skipped\r\n const lastWord: ITossupWord = tossupWords[tossupWords.length - 1];\r\n if (!lastWord.canBuzzOn) {\r\n // Something weird is happening, since the last word should always be buzzable (as the end marker).\r\n throw new Error(\"Last word not buzzable, but must be buzzable by design\");\r\n }\r\n\r\n return wordIndex >= lastWord.wordIndex ? 0 : format.negValue;\r\n }\r\n\r\n // Not in power, so return the default value\r\n return 10;\r\n }\r\n\r\n public getWords(format: IGameFormat): ITossupWord[] {\r\n const formattedTexts: IFormattedText[][] = this.formattedQuestionText(format);\r\n const words: ITossupWord[] = [];\r\n let wordIndex = 0;\r\n let nonwordIndex = 0;\r\n for (let i = 0; i < formattedTexts.length; i++) {\r\n const word: IFormattedText[] = formattedTexts[i];\r\n const fullText = word.reduce((result, text) => result + text.text, \"\");\r\n const isLastWord: boolean = i === formattedTexts.length - 1;\r\n const inPronunciationGuide: boolean = word.length > 0 && word[0].pronunciation === true;\r\n\r\n // We need to skip over power markers and not count them when we calculate buzz points\r\n let canBuzzOn = true;\r\n let index: number = wordIndex;\r\n const trimmedText: string = fullText.trim();\r\n const powerMarkerIndex: number = format.powers.findIndex((power) => trimmedText.startsWith(power.marker));\r\n if (isLastWord) {\r\n // Last word should always be the terminal character, which can't be a power or in a pronunciation guide\r\n wordIndex++;\r\n } else if (powerMarkerIndex >= 0) {\r\n // Power markers have priority over pronunciation guides, and shouldn't be treated as such\r\n for (const segment of word) {\r\n segment.pronunciation = false;\r\n }\r\n\r\n canBuzzOn = false;\r\n index = nonwordIndex;\r\n nonwordIndex++;\r\n } else if (inPronunciationGuide) {\r\n canBuzzOn = false;\r\n index = nonwordIndex;\r\n nonwordIndex++;\r\n } else {\r\n wordIndex++;\r\n }\r\n\r\n if (canBuzzOn) {\r\n words.push({\r\n wordIndex: index,\r\n textIndex: i,\r\n word,\r\n isLastWord,\r\n canBuzzOn,\r\n });\r\n } else {\r\n words.push({\r\n nonWordIndex: index,\r\n textIndex: i,\r\n word,\r\n canBuzzOn,\r\n });\r\n }\r\n }\r\n\r\n return words;\r\n }\r\n\r\n private formattedQuestionText(format: IGameFormat): IFormattedText[][] {\r\n // Include the ■ to give an end of question marker\r\n return FormattedTextParser.splitFormattedTextIntoWords(this.question, {\r\n pronunciationGuideMarkers: format.pronunciationGuideMarkers,\r\n }).concat([[{ text: \"■END■\", bolded: true, emphasized: false, required: false, pronunciation: false }]]);\r\n }\r\n}\r\n\r\nexport class Bonus {\r\n public leadin: string;\r\n\r\n public parts: BonusPart[];\r\n\r\n public metadata: string | undefined;\r\n\r\n constructor(leadin: string, parts: BonusPart[], metadata?: string) {\r\n // We don't use makeAutoObservable because leadin doesn't need to be observable (never changes)\r\n makeAutoObservable(this);\r\n\r\n this.leadin = leadin.trim();\r\n this.parts = parts;\r\n this.metadata = metadata;\r\n }\r\n}\r\n\r\nexport function getBonusWords(text: string, format: IGameFormat): IFormattedText[] {\r\n return FormattedTextParser.parseFormattedText(text, {\r\n pronunciationGuideMarkers: format.pronunciationGuideMarkers,\r\n });\r\n}\r\n\r\nexport type ITossupWord = IBuzzableTossupWord | INonbuzzableTossupWord;\r\n\r\nexport interface IBuzzableTossupWord extends IBaseTossupWord {\r\n wordIndex: number;\r\n isLastWord: boolean;\r\n canBuzzOn: true;\r\n}\r\n\r\nexport interface INonbuzzableTossupWord extends IBaseTossupWord {\r\n nonWordIndex: number;\r\n canBuzzOn: false;\r\n}\r\n\r\ninterface IBaseTossupWord {\r\n word: IFormattedText[];\r\n textIndex: number;\r\n}\r\n","import { computed, observable, action, makeObservable, when } from \"mobx\";\r\nimport { format } from \"mobx-sync\";\r\n\r\nimport * as GameFormats from \"./GameFormats\";\r\nimport * as PlayerUtils from \"./PlayerUtils\";\r\nimport { PacketState, Bonus, Tossup } from \"./PacketState\";\r\nimport { IPlayer, Player } from \"./TeamState\";\r\nimport { Cycle, ICycle } from \"./Cycle\";\r\nimport {\r\n ISubstitutionEvent,\r\n IPlayerJoinsEvent,\r\n IPlayerLeavesEvent,\r\n IBonusAnswerPart,\r\n ITossupAnswerEvent,\r\n} from \"./Events\";\r\nimport { IGameFormat } from \"./IGameFormat\";\r\n\r\nexport class GameState {\r\n public packet: PacketState;\r\n\r\n @format((deserializedArray: IPlayer[]) => deserializedArray.map((p) => new Player(p.name, p.teamName, p.isStarter)))\r\n public players: Player[];\r\n\r\n // In general we should prefer playableCycles, but if it's used for updating cycles directly, then\r\n // using cycles can be safer since we're less likely to have an issue with the index being out of bounds\r\n // Anything with methods/computeds not at the top level needs to use @format to deserialize correctly\r\n @format((deserializedArray: ICycle[], currentCycles: Cycle[]) => {\r\n // This is sometimes called twice, and the second time the old value is the same as the new one. In that case,\r\n // just return the current value. This fixes an issue where the update handler on the cycle gets wiped out.\r\n if (deserializedArray === currentCycles) {\r\n return currentCycles;\r\n }\r\n\r\n return deserializedArray.map((deserializedCycle) => {\r\n return new Cycle(deserializedCycle);\r\n });\r\n })\r\n public cycles: Cycle[];\r\n\r\n @format((deserializedFormat: IGameFormat) => GameFormats.getUpgradedFormatVersion(deserializedFormat))\r\n public gameFormat: IGameFormat;\r\n\r\n public hasUpdates: boolean;\r\n\r\n constructor() {\r\n makeObservable(this, {\r\n cycles: observable,\r\n teamNames: computed,\r\n gameFormat: observable,\r\n hasUpdates: observable,\r\n markUpdateComplete: action,\r\n packet: observable,\r\n players: observable,\r\n isLoaded: computed,\r\n finalScore: computed,\r\n playableCycles: computed,\r\n scores: computed,\r\n addInactivePlayer: action,\r\n addNewPlayer: action,\r\n addNewPlayers: action,\r\n clear: action,\r\n loadPacket: action,\r\n setCycles: action,\r\n setGameFormat: action,\r\n removeNewPlayer: action,\r\n setPlayers: action,\r\n });\r\n\r\n this.packet = new PacketState();\r\n this.players = [];\r\n this.cycles = [];\r\n this.gameFormat = GameFormats.UndefinedGameFormat;\r\n this.hasUpdates = false;\r\n\r\n // Once we've filled out all the cycles, then add the update handlers to all of the cycles. This is needed\r\n // because we can't do this when getting data from deserialized cycles (no access to this in the decorator)\r\n when(\r\n () => this.cycles.length >= this.gameFormat.regulationTossupCount,\r\n () => {\r\n for (const cycle of this.cycles) {\r\n cycle.setUpdateHandler(() => this.markUpdateNeeded());\r\n }\r\n }\r\n );\r\n }\r\n\r\n public get isLoaded(): boolean {\r\n return this.packet.tossups.length > 0;\r\n }\r\n\r\n public get teamNames(): string[] {\r\n // There should be very few teams names (really two)\r\n if (this.players.length === 0) {\r\n return [];\r\n }\r\n\r\n const seenTeams: Set = new Set();\r\n\r\n const firstTeamName = this.players[0].teamName;\r\n seenTeams.add(firstTeamName);\r\n const teamNames = [firstTeamName];\r\n\r\n for (let i = 1; i < this.players.length; i++) {\r\n const teamName = this.players[i].teamName;\r\n if (!seenTeams.has(teamName)) {\r\n seenTeams.add(teamName);\r\n teamNames.push(teamName);\r\n }\r\n }\r\n\r\n return teamNames;\r\n }\r\n\r\n public get finalScore(): number[] {\r\n return this.scores[this.playableCycles.length - 1];\r\n }\r\n\r\n public get playableCycles(): Cycle[] {\r\n if (this.cycles.length <= this.gameFormat.regulationTossupCount) {\r\n return this.cycles;\r\n }\r\n\r\n // Check if the game is tied at the end of regulation and at the end of each overtime period. If it isn't,\r\n // return those cycles.\r\n const score: number[][] = this.scores;\r\n for (\r\n let i = this.gameFormat.regulationTossupCount - 1;\r\n i < this.cycles.length;\r\n i += this.gameFormat.minimumOvertimeQuestionCount\r\n ) {\r\n const scoreAtInterval: number[] = score[i];\r\n let isTied = false;\r\n let maxScore = -Infinity;\r\n for (const teamScore of scoreAtInterval) {\r\n if (teamScore > maxScore) {\r\n maxScore = teamScore;\r\n isTied = false;\r\n } else if (teamScore === maxScore) {\r\n isTied = true;\r\n }\r\n }\r\n\r\n if (!isTied) {\r\n return this.cycles.slice(0, i + 1);\r\n }\r\n }\r\n\r\n return this.cycles;\r\n }\r\n\r\n public get scores(): number[][] {\r\n const score: number[][] = [];\r\n const previousScores: number[] = Array.from(this.teamNames, () => 0);\r\n\r\n // We should keep calculating until we're at the end of regulation or there are more tiebreaker questions\r\n // needed\r\n for (const cycle of this.cycles) {\r\n const scoreChange: number[] = this.getScoreChangeFromCycle(cycle);\r\n for (let i = 0; i < scoreChange.length; i++) {\r\n previousScores[i] += scoreChange[i];\r\n }\r\n\r\n score.push([...previousScores]);\r\n }\r\n\r\n return score;\r\n }\r\n\r\n public get protestsMatter(): boolean {\r\n if (this.finalScore == undefined || this.finalScore.length < 2) {\r\n return false;\r\n }\r\n\r\n if (this.finalScore[0] === this.finalScore[1]) {\r\n // If there are any protests, they matter.\r\n return this.cycles.some(\r\n (cycle) =>\r\n (cycle.tossupProtests != undefined && cycle.tossupProtests.length > 0) ||\r\n (cycle.bonusProtests != undefined && cycle.bonusProtests.length > 0)\r\n );\r\n }\r\n\r\n const leadingTeamIndex: number = this.finalScore[0] > this.finalScore[1] ? 0 : 1;\r\n const losingTeamIndex: number = leadingTeamIndex === -1 ? -1 : 1 - leadingTeamIndex;\r\n\r\n // Protests only matter if the losing team's protests would be enough to get them to tie\r\n return (\r\n this.protestSwings[losingTeamIndex][losingTeamIndex] >=\r\n this.protestSwings[losingTeamIndex][leadingTeamIndex]\r\n );\r\n }\r\n\r\n // Returns the best possible outcome for team 1, and the best possible outcome for team 2\r\n private get protestSwings(): [[number, number], [number, number]] {\r\n const swings: [IProtestSwing, IProtestSwing] = [\r\n { against: 0, for: 0 },\r\n { against: 0, for: 0 },\r\n ];\r\n\r\n for (let i = 0; i < this.cycles.length; i++) {\r\n const cycle: Cycle = this.cycles[i];\r\n\r\n if (cycle.tossupProtests) {\r\n for (const tossupProtest of cycle.tossupProtests) {\r\n // Don't use getTossup because the protest could've been in a thrown-out tossup\r\n const tossup: Tossup | undefined = this.packet.tossups[tossupProtest.questionIndex];\r\n\r\n if (tossup != undefined) {\r\n let tossupTeamIndex: number = tossupProtest.teamName === this.teamNames[0] ? 0 : 1;\r\n if (cycle.correctBuzz) {\r\n // If the correct buzz is for the protesting team, then this should be for \"against\"\r\n if (\r\n cycle.correctBuzz.tossupIndex === tossupProtest.questionIndex &&\r\n cycle.correctBuzz.marker.player.teamName === tossupProtest.teamName\r\n ) {\r\n tossupTeamIndex = 1 - tossupTeamIndex;\r\n }\r\n\r\n // We have a correct buzz... we need to discount this buzz for the other team, plus any\r\n // bonus they got\r\n const correctTossupPoints = tossup.getPointsAtPosition(\r\n this.gameFormat,\r\n tossupProtest.position,\r\n /* isCorrect */ true\r\n );\r\n\r\n // Need to include correct parts and bouncebacks (the -1 * current.points part)\r\n const bonusPoints =\r\n cycle.bonusAnswer?.parts.reduce(\r\n (previous, current) =>\r\n (cycle.correctBuzz?.marker.player.teamName === current.teamName\r\n ? current.points\r\n : -1 * current.points) + previous,\r\n 0\r\n ) ?? 0;\r\n\r\n swings[1 - tossupTeamIndex].against += correctTossupPoints + bonusPoints;\r\n }\r\n\r\n const bonusIndex: number = this.getBonusIndex(i);\r\n const potentialBonusPoints = this.packet.bonuses[bonusIndex].parts.reduce(\r\n (previous, current) => current.value + previous,\r\n 0\r\n );\r\n\r\n // Need to remove the neg (subtract) and add what the correct value would be\r\n swings[tossupTeamIndex].for +=\r\n tossup.getPointsAtPosition(this.gameFormat, tossupProtest.position, /* isCorrect */ true) -\r\n tossup.getPointsAtPosition(this.gameFormat, tossupProtest.position, /* isCorrect */ false) +\r\n potentialBonusPoints;\r\n }\r\n }\r\n }\r\n\r\n if (cycle.bonusProtests && cycle.correctBuzz) {\r\n const correctBuzzTeamName: string = cycle.correctBuzz.marker.player.teamName;\r\n\r\n for (const bonusProtest of cycle.bonusProtests) {\r\n // Don't use getBonus because the protest could've been in a bonus that was then replaced\r\n const bonus: Bonus | undefined = this.packet.bonuses[bonusProtest.questionIndex];\r\n if (bonus != undefined) {\r\n const bonusTeamIndex: number = correctBuzzTeamName === this.teamNames[0] ? 0 : 1;\r\n const value: number = bonus.parts[bonusProtest.partIndex].value;\r\n if (bonusProtest.teamName === correctBuzzTeamName) {\r\n swings[bonusTeamIndex].for += value;\r\n } else {\r\n swings[bonusTeamIndex].against += value;\r\n }\r\n }\r\n }\r\n }\r\n }\r\n\r\n // [Best outcome for team 1, best outcome for team 2]\r\n return [\r\n [this.finalScore[0] + swings[0].for, this.finalScore[1] - swings[1].against],\r\n [this.finalScore[0] - swings[0].against, this.finalScore[1] + swings[1].for],\r\n ];\r\n }\r\n\r\n private static updateTeamNameIfNeeded(object: { teamName: string }, oldName: string, newName: string): void {\r\n if (object.teamName === oldName) {\r\n object.teamName = newName;\r\n }\r\n }\r\n\r\n public addInactivePlayer(player: Player, cycleIndex: number): void {\r\n for (let i = cycleIndex; i >= 0; i--) {\r\n const cycle: Cycle = this.cycles[i];\r\n\r\n if (\r\n cycle.playerJoins &&\r\n cycle.playerJoins.some((joinEvent) => PlayerUtils.playersEqual(player, joinEvent.inPlayer))\r\n ) {\r\n // We have a current or earlier join event with no leave events. Adding a player now is a no-op.\r\n return;\r\n }\r\n\r\n if (cycle.subs) {\r\n if (cycle.subs.some((subEvent) => PlayerUtils.playersEqual(player, subEvent.inPlayer))) {\r\n // We have a current or earlier join event with no leave events. Adding a player now is a no-op.\r\n return;\r\n } else if (cycle.subs.some((subEvent) => PlayerUtils.playersEqual(player, subEvent.outPlayer))) {\r\n // Need to test subbing someone out and also having them join. Very strange set of events.\r\n break;\r\n }\r\n }\r\n\r\n if (\r\n cycle.playerLeaves &&\r\n cycle.playerLeaves.some((leaveEvent) => PlayerUtils.playersEqual(player, leaveEvent.outPlayer))\r\n ) {\r\n // If it's the same cycle as the current one, remove the playerLeaves event, since this has priority\r\n // as we're explicitly adding it afterwards\r\n if (i == cycleIndex) {\r\n const leaveEvent: IPlayerLeavesEvent | undefined = cycle.playerLeaves.find((leaveEvent) =>\r\n PlayerUtils.playersEqual(player, leaveEvent.outPlayer)\r\n );\r\n\r\n if (leaveEvent != undefined) {\r\n cycle.removePlayerLeaves(leaveEvent);\r\n }\r\n }\r\n\r\n break;\r\n }\r\n }\r\n\r\n for (let i = cycleIndex + 1; i < this.cycles.length; i++) {\r\n const cycle: Cycle = this.cycles[i];\r\n\r\n if (\r\n cycle.playerLeaves &&\r\n cycle.playerLeaves.some((leaveEvent) => PlayerUtils.playersEqual(player, leaveEvent.outPlayer))\r\n ) {\r\n break;\r\n } else if (\r\n cycle.subs &&\r\n cycle.subs.some((subEvent) => PlayerUtils.playersEqual(player, subEvent.outPlayer))\r\n ) {\r\n break;\r\n }\r\n\r\n if (cycle.subs) {\r\n if (cycle.subs.some((subEvent) => PlayerUtils.playersEqual(player, subEvent.inPlayer))) {\r\n // We're adding the player earlier, and there's no leave event. Remove that event and then add the new\r\n // one\r\n const subEvent: IPlayerJoinsEvent | undefined = cycle.subs.find(\r\n (joinEvent) => !PlayerUtils.playersEqual(player, joinEvent.inPlayer)\r\n );\r\n if (subEvent) {\r\n cycle.removePlayerJoins(subEvent);\r\n }\r\n\r\n break;\r\n } else if (cycle.subs.some((subEvent) => PlayerUtils.playersEqual(player, subEvent.outPlayer))) {\r\n break;\r\n }\r\n }\r\n\r\n if (\r\n cycle.playerJoins &&\r\n cycle.playerJoins.some((joinEvent) => PlayerUtils.playersEqual(player, joinEvent.inPlayer))\r\n ) {\r\n // We're adding the player earlier, and there's no leave event. Remove that event and then add the new\r\n // one\r\n const joinEvent: IPlayerJoinsEvent | undefined = cycle.playerJoins.find((joinEvent) =>\r\n PlayerUtils.playersEqual(player, joinEvent.inPlayer)\r\n );\r\n if (joinEvent) {\r\n cycle.removePlayerJoins(joinEvent);\r\n }\r\n\r\n break;\r\n }\r\n }\r\n\r\n this.cycles[cycleIndex].addPlayerJoins(player);\r\n }\r\n\r\n public addNewPlayer(player: Player): void {\r\n this.players.push(player);\r\n }\r\n\r\n public addNewPlayers(players: Player[]): void {\r\n this.players.push(...players);\r\n }\r\n\r\n public clear(): void {\r\n this.packet = new PacketState();\r\n this.players = [];\r\n this.cycles = [];\r\n }\r\n\r\n public getActivePlayers(teamName: string, cycleIndex: number): Set {\r\n // If there's no cycles at that index, then there are no active players\r\n if (cycleIndex >= this.cycles.length) {\r\n return new Set();\r\n }\r\n\r\n const players: Player[] = this.getPlayers(teamName);\r\n const activePlayers: Set = new Set(players.filter((player) => player.isStarter));\r\n\r\n // We should just have starters at the beginning. Then swap out new players, based on substitutions up to the\r\n // cycleIndex.\r\n for (let i = 0; i <= cycleIndex; i++) {\r\n const cycle: Cycle = this.cycles[i];\r\n const subs: ISubstitutionEvent[] | undefined = cycle.subs;\r\n const joins: IPlayerJoinsEvent[] | undefined = cycle.playerJoins;\r\n const leaves: IPlayerLeavesEvent[] | undefined = cycle.playerLeaves;\r\n\r\n if (subs == undefined && joins == undefined && leaves == undefined) {\r\n continue;\r\n }\r\n\r\n const teamLeaves: IPlayerLeavesEvent[] =\r\n leaves?.filter((leave) => leave.outPlayer.teamName === teamName) ?? [];\r\n for (const leave of teamLeaves) {\r\n const outPlayer: Player | undefined = players.find((player) => player.name === leave.outPlayer.name);\r\n if (outPlayer == undefined) {\r\n throw new Error(\r\n `Tried to take out ${leave.outPlayer.name} from the game, who isn't on team ${teamName}`\r\n );\r\n }\r\n\r\n activePlayers.delete(outPlayer);\r\n }\r\n\r\n const teamJoins: IPlayerJoinsEvent[] = joins?.filter((join) => join.inPlayer.teamName === teamName) ?? [];\r\n for (const join of teamJoins) {\r\n const inPlayer: Player | undefined = players.find((player) => player.name === join.inPlayer.name);\r\n if (inPlayer == undefined) {\r\n throw new Error(`Tried to add ${join.inPlayer.name}, who isn't on team ${teamName}`);\r\n }\r\n\r\n activePlayers.add(inPlayer);\r\n }\r\n\r\n const teamSubs: ISubstitutionEvent[] = subs?.filter((sub) => sub.inPlayer.teamName === teamName) ?? [];\r\n for (const sub of teamSubs) {\r\n const inPlayer = players.find((player) => player.name === sub.inPlayer.name);\r\n const outPlayer = players.find((player) => player.name === sub.outPlayer.name);\r\n\r\n if (inPlayer == undefined) {\r\n throw new Error(\r\n `Tried to substitute in player ${sub.inPlayer.name}, who isn't on team ${teamName}`\r\n );\r\n } else if (outPlayer == undefined) {\r\n throw new Error(\r\n `Tried to substitute out player ${sub.outPlayer.name}, who isn't on team ${teamName}`\r\n );\r\n }\r\n\r\n activePlayers.add(inPlayer);\r\n activePlayers.delete(outPlayer);\r\n }\r\n }\r\n\r\n return activePlayers;\r\n }\r\n\r\n // TODO: Make this return a set?\r\n public getPlayers(teamName: string): Player[] {\r\n return this.players.filter((player) => player.teamName === teamName);\r\n }\r\n\r\n public getBonus(cycleIndex: number): Bonus | undefined {\r\n return this.packet.bonuses[this.getBonusIndex(cycleIndex)];\r\n }\r\n\r\n public getBonusIndex(cycleIndex: number): number {\r\n if (this.gameFormat.pairTossupsBonuses) {\r\n // Same as the cycle index plus thrown out questions\r\n let thrownOutBonusesCount = 0;\r\n for (let i = 0; i <= cycleIndex; i++) {\r\n const cycle: Cycle = this.cycles[i];\r\n if (cycle.thrownOutBonuses !== undefined) {\r\n thrownOutBonusesCount += cycle.thrownOutBonuses.length;\r\n }\r\n }\r\n\r\n const index = cycleIndex + thrownOutBonusesCount;\r\n return index >= this.packet.bonuses.length ? -1 : index;\r\n }\r\n\r\n const previousCycleIndex: number = cycleIndex - 1;\r\n let usedBonusesCount = 0;\r\n for (let i = 0; i <= cycleIndex; i++) {\r\n const cycle = this.cycles[i];\r\n if (cycle.correctBuzz != undefined && i <= previousCycleIndex) {\r\n usedBonusesCount++;\r\n }\r\n\r\n if (cycle.thrownOutBonuses != undefined) {\r\n usedBonusesCount += cycle.thrownOutBonuses.length;\r\n }\r\n }\r\n\r\n return usedBonusesCount >= this.packet.bonuses.length ? -1 : usedBonusesCount;\r\n }\r\n\r\n public getBuzzValue(buzz: ITossupAnswerEvent): number {\r\n const tossup: Tossup | undefined = this.packet.tossups[buzz.tossupIndex];\r\n if (tossup == undefined) {\r\n return 0;\r\n }\r\n\r\n return tossup.getPointsAtPosition(\r\n this.gameFormat,\r\n buzz.marker.position,\r\n /* isCorrect */ buzz.marker.points > 0\r\n );\r\n }\r\n\r\n public getTossup(cycleIndex: number): Tossup | undefined {\r\n return this.packet.tossups[this.getTossupIndex(cycleIndex)];\r\n }\r\n\r\n public getTossupIndex(cycleIndex: number): number {\r\n let thrownOutTossupsCount = 0;\r\n for (let i = 0; i <= cycleIndex; i++) {\r\n const cycle: Cycle = this.cycles[i];\r\n if (cycle.thrownOutTossups !== undefined) {\r\n thrownOutTossupsCount += cycle.thrownOutTossups.length;\r\n }\r\n }\r\n\r\n return cycleIndex + thrownOutTossupsCount;\r\n }\r\n\r\n public loadPacket(packet: PacketState): void {\r\n this.packet = packet;\r\n\r\n if (this.cycles.length < this.packet.tossups.length) {\r\n const handler = () => this.markUpdateNeeded();\r\n for (let i = this.cycles.length; i < this.packet.tossups.length; i++) {\r\n const cycle: Cycle = new Cycle();\r\n cycle.setUpdateHandler(handler);\r\n this.cycles.push(cycle);\r\n }\r\n }\r\n }\r\n\r\n public markUpdateNeeded(): void {\r\n this.hasUpdates = true;\r\n }\r\n\r\n public markUpdateComplete(): void {\r\n this.hasUpdates = false;\r\n }\r\n\r\n public removeNewPlayer(player: IPlayer): void {\r\n const playersSize = this.players.length;\r\n this.players = this.players.filter((p) => p !== player);\r\n\r\n if (this.players.length === playersSize) {\r\n return;\r\n }\r\n\r\n // We have to go through future cycles and remove events where the player is mentioned. This should be handled\r\n // inside of the cycle.\r\n for (const cycle of this.cycles) {\r\n cycle.removeNewPlayerEvents(player);\r\n }\r\n\r\n this.hasUpdates = true;\r\n }\r\n\r\n public setCycles(cycles: Cycle[]): void {\r\n this.cycles = cycles;\r\n }\r\n\r\n public setGameFormat(gameFormat: IGameFormat): void {\r\n this.hasUpdates = true;\r\n this.gameFormat = gameFormat;\r\n }\r\n\r\n public setPlayers(players: Player[]): void {\r\n this.hasUpdates = true;\r\n this.players = players;\r\n }\r\n\r\n public tryUpdatePlayerName(playerTeam: string, oldPlayerName: string, newPlayerName: string): boolean {\r\n let player: IPlayer | undefined;\r\n for (const p of this.players) {\r\n if (p.teamName === playerTeam && p.name === oldPlayerName) {\r\n if (p.name === oldPlayerName) {\r\n player = p;\r\n } else if (p.name === newPlayerName) {\r\n // Can't update the player, since that name already exists\r\n return false;\r\n }\r\n }\r\n }\r\n\r\n if (player == undefined) {\r\n return false;\r\n }\r\n\r\n player.name = newPlayerName;\r\n\r\n // Setting player.name to newPlayerName doesn't work if you're going off of a copy of the player\r\n // Go through and update all events manually.\r\n // If we need this to be more performant, we should be able to get away with updating the name just once,\r\n // but we do it everywhere here in case we use a copy (.e.g. like {...player})\r\n for (const cycle of this.cycles) {\r\n if (\r\n cycle.correctBuzz != undefined &&\r\n cycle.correctBuzz.marker.player.name === oldPlayerName &&\r\n cycle.correctBuzz.marker.player.teamName === playerTeam\r\n ) {\r\n cycle.correctBuzz.marker.player.name = newPlayerName;\r\n }\r\n\r\n if (cycle.wrongBuzzes != undefined) {\r\n for (const wrongBuzz of cycle.wrongBuzzes) {\r\n if (\r\n wrongBuzz.marker.player.name === oldPlayerName &&\r\n wrongBuzz.marker.player.teamName === playerTeam\r\n ) {\r\n wrongBuzz.marker.player.name = newPlayerName;\r\n }\r\n }\r\n }\r\n\r\n if (cycle.playerJoins != undefined) {\r\n for (const join of cycle.playerJoins) {\r\n if (join.inPlayer.name === oldPlayerName && join.inPlayer.teamName === playerTeam) {\r\n join.inPlayer.name = newPlayerName;\r\n }\r\n }\r\n }\r\n\r\n if (cycle.playerLeaves != undefined) {\r\n for (const leave of cycle.playerLeaves) {\r\n if (leave.outPlayer.name === oldPlayerName && leave.outPlayer.teamName === playerTeam) {\r\n leave.outPlayer.name = newPlayerName;\r\n }\r\n }\r\n }\r\n\r\n if (cycle.subs != undefined) {\r\n // Could be in or out player\r\n for (const sub of cycle.subs) {\r\n if (sub.inPlayer.name === oldPlayerName && sub.inPlayer.teamName === playerTeam) {\r\n sub.inPlayer.name = newPlayerName;\r\n } else if (sub.outPlayer.name === oldPlayerName && sub.outPlayer.teamName === playerTeam) {\r\n sub.outPlayer.name = newPlayerName;\r\n }\r\n }\r\n }\r\n }\r\n\r\n return true;\r\n }\r\n\r\n public tryUpdateTeamName(oldTeamName: string, newTeamName: string): boolean {\r\n newTeamName = newTeamName.trim();\r\n if (newTeamName === \"\") {\r\n return false;\r\n }\r\n\r\n // If the names are the same, no update is needed\r\n if (oldTeamName === newTeamName) {\r\n return true;\r\n }\r\n\r\n // Need to make sure we won't have two teams that have the new name\r\n for (const teamName of this.teamNames) {\r\n if (teamName === newTeamName) {\r\n return false;\r\n }\r\n }\r\n\r\n // Update all player team names matching this\r\n for (const player of this.players) {\r\n if (player.teamName === oldTeamName) {\r\n player.setTeamName(newTeamName);\r\n }\r\n }\r\n\r\n // Update all cycles that use this team name. Follow the guidance from tryUpdatePlayerName\r\n // If we need this to be more performant, we should be able to get away with updating the name just once,\r\n // but we do it everywhere here in case we use a copy (.e.g. like {...player}). Mobx may block this sometimes\r\n for (const cycle of this.cycles) {\r\n if (cycle.correctBuzz != undefined) {\r\n GameState.updateTeamNameIfNeeded(cycle.correctBuzz.marker.player, oldTeamName, newTeamName);\r\n }\r\n\r\n if (cycle.wrongBuzzes != undefined) {\r\n for (const wrongBuzz of cycle.wrongBuzzes) {\r\n GameState.updateTeamNameIfNeeded(wrongBuzz.marker.player, oldTeamName, newTeamName);\r\n }\r\n }\r\n\r\n if (cycle.bonusAnswer != undefined) {\r\n if (cycle.bonusAnswer.receivingTeamName === oldTeamName) {\r\n cycle.bonusAnswer.receivingTeamName = newTeamName;\r\n }\r\n\r\n if (cycle.bonusAnswer.parts != undefined) {\r\n for (const part of cycle.bonusAnswer.parts) {\r\n GameState.updateTeamNameIfNeeded(part, oldTeamName, newTeamName);\r\n }\r\n }\r\n }\r\n\r\n if (cycle.timeouts) {\r\n for (const timeout of cycle.timeouts) {\r\n GameState.updateTeamNameIfNeeded(timeout, oldTeamName, newTeamName);\r\n }\r\n }\r\n\r\n if (cycle.playerJoins != undefined) {\r\n for (const join of cycle.playerJoins) {\r\n GameState.updateTeamNameIfNeeded(join.inPlayer, oldTeamName, newTeamName);\r\n }\r\n }\r\n\r\n if (cycle.playerLeaves != undefined) {\r\n for (const leave of cycle.playerLeaves) {\r\n GameState.updateTeamNameIfNeeded(leave.outPlayer, oldTeamName, newTeamName);\r\n }\r\n }\r\n\r\n if (cycle.subs != undefined) {\r\n // Could be in or out player\r\n for (const sub of cycle.subs) {\r\n GameState.updateTeamNameIfNeeded(sub.inPlayer, oldTeamName, newTeamName);\r\n GameState.updateTeamNameIfNeeded(sub.outPlayer, oldTeamName, newTeamName);\r\n }\r\n }\r\n\r\n if (cycle.tossupProtests != undefined) {\r\n for (const protest of cycle.tossupProtests) {\r\n GameState.updateTeamNameIfNeeded(protest, oldTeamName, newTeamName);\r\n }\r\n }\r\n\r\n if (cycle.bonusProtests != undefined) {\r\n for (const protest of cycle.bonusProtests) {\r\n GameState.updateTeamNameIfNeeded(protest, oldTeamName, newTeamName);\r\n }\r\n }\r\n }\r\n\r\n return true;\r\n }\r\n\r\n private getScoreChangeFromCycle(cycle: Cycle): number[] {\r\n const change: number[] = Array.from(this.teamNames, () => 0);\r\n if (cycle.correctBuzz) {\r\n const indexToUpdate: number = this.teamNames.indexOf(cycle.correctBuzz.marker.player.teamName);\r\n if (indexToUpdate < 0) {\r\n throw new Error(\r\n `Correct buzz belongs to a non-existent team ${cycle.correctBuzz.marker.player.teamName}`\r\n );\r\n }\r\n\r\n change[indexToUpdate] += this.getBuzzValue(cycle.correctBuzz);\r\n if (cycle.bonusAnswer) {\r\n for (let i = 0; i < cycle.bonusAnswer.parts.length; i++) {\r\n let bonusAnswerTeamIndex: number = indexToUpdate;\r\n const part: IBonusAnswerPart = cycle.bonusAnswer.parts[i];\r\n if (part.teamName === \"\") {\r\n // No team scored this part, skip it\r\n continue;\r\n } else if (part.teamName !== cycle.correctBuzz.marker.player.teamName) {\r\n bonusAnswerTeamIndex = this.teamNames.indexOf(part.teamName);\r\n if (bonusAnswerTeamIndex === -1) {\r\n // Bad part, skip this\r\n continue;\r\n }\r\n }\r\n\r\n change[bonusAnswerTeamIndex] += part.points;\r\n }\r\n }\r\n }\r\n\r\n if (cycle.wrongBuzzes != undefined && cycle.wrongBuzzes.length > 0 && this.gameFormat.negValue !== 0) {\r\n const negBuzz: ITossupAnswerEvent | undefined = cycle.firstWrongBuzz;\r\n if (negBuzz == undefined) {\r\n throw new Error(\"Neg couldn't be found in list of non-empty incorrect buzzes\");\r\n }\r\n\r\n const indexToUpdate: number = this.teamNames.indexOf(negBuzz.marker.player.teamName);\r\n if (indexToUpdate < 0) {\r\n throw new Error(`Wrong buzz belongs to a non-existent team ${negBuzz.marker.player.teamName}`);\r\n }\r\n\r\n change[indexToUpdate] += this.getBuzzValue(negBuzz);\r\n }\r\n\r\n return change;\r\n }\r\n}\r\n\r\ninterface IProtestSwing {\r\n against: number;\r\n for: number;\r\n}\r\n","import { Player } from \"../state/TeamState\";\r\nimport { Cycle } from \"../state/Cycle\";\r\nimport { IBonusAnswerPart, ITossupAnswerEvent } from \"../state/Events\";\r\nimport { GameState } from \"../state/GameState\";\r\nimport { IResult } from \"../IResult\";\r\n\r\nexport function parseRegistration(json: string): IResult {\r\n // Either it's a JSON object with \"name\" or a JSON array\r\n const parsedInput: IRegistration[] | ITournament = JSON.parse(json);\r\n let registrations: IRegistration[];\r\n if (isTournament(parsedInput)) {\r\n registrations = parsedInput.registrations;\r\n } else {\r\n registrations = parsedInput;\r\n }\r\n\r\n if (!Array.isArray(registrations)) {\r\n return {\r\n success: false,\r\n message: \"No list of registrations found in the file.\",\r\n };\r\n }\r\n\r\n const teamCounts: Map = new Map();\r\n const players: Player[] = [];\r\n for (const registration of registrations) {\r\n if (registration.teams == undefined || !Array.isArray(registration.teams)) {\r\n return {\r\n success: false,\r\n message: \"Registration is missing a teams field.\",\r\n };\r\n }\r\n\r\n for (const team of registration.teams) {\r\n if (team.name == undefined) {\r\n return {\r\n success: false,\r\n message: `Registration is either missing a team name or has a null value for the team name.`,\r\n };\r\n } else if (team.players == undefined) {\r\n return {\r\n success: false,\r\n message: `Registration is missing a players field for team '${team.name}'.`,\r\n };\r\n } else if (!Array.isArray(team.players) || team.players.length === 0) {\r\n return {\r\n success: false,\r\n message: `Registration has an empty or incorrect players field for team '${team.name}'.`,\r\n };\r\n }\r\n\r\n for (const player of team.players) {\r\n if (player.name == undefined) {\r\n return {\r\n success: false,\r\n message: `Registration has a player with no name on team '${team.name}'.`,\r\n };\r\n }\r\n\r\n let playerCount = teamCounts.get(team.name);\r\n if (playerCount == undefined) {\r\n playerCount = 0;\r\n }\r\n\r\n playerCount++;\r\n teamCounts.set(team.name, playerCount);\r\n\r\n // TODO: The isStarter value should be determined by the format\r\n players.push(new Player(player.name, team.name, playerCount <= 4));\r\n }\r\n }\r\n }\r\n\r\n if (players.length === 0) {\r\n return {\r\n success: false,\r\n message: \"Registration has no players\",\r\n };\r\n }\r\n\r\n return { success: true, value: players };\r\n}\r\n\r\n// Needed for the type guard\r\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\r\nfunction isTournament(registrations: any): registrations is ITournament {\r\n return registrations.name != undefined && registrations.registrations != undefined;\r\n}\r\n\r\n// Converts games into a QBJ file that conforms to the Match interface in the QB Schema\r\nexport function toQBJString(game: GameState, packetName?: string, round?: number): string {\r\n return JSON.stringify(toQBJ(game, packetName, round));\r\n}\r\n\r\nexport function toQBJ(game: GameState, packetName?: string, round?: number): IMatch {\r\n // Convert it to a Match, then use JSON.stringify\r\n\r\n const players: IPlayer[] = [];\r\n const teams: ITeam[] = game.teamNames.map((name) => {\r\n return {\r\n name,\r\n players: [],\r\n };\r\n });\r\n\r\n const teamNames: string[] = game.teamNames;\r\n const noteworthyEvents: string[] = [];\r\n\r\n // teamLineups tracks the lineup throughout the game, sowe can addd new ones to matchTeams easily\r\n const teamLineups: Map = new Map();\r\n const teamPlayers: Map = new Map();\r\n const matchTeams: Map = new Map();\r\n for (const teamName of teamNames) {\r\n const firstLineup: ILineup = {\r\n first_question: 1,\r\n players: [],\r\n };\r\n teamLineups.set(teamName, firstLineup);\r\n teamPlayers.set(teamName, []);\r\n\r\n const team: ITeam | undefined = teams.find((t) => t.name === teamName);\r\n if (team) {\r\n matchTeams.set(teamName, {\r\n bonus_points: 0,\r\n bonus_bounceback_points: game.gameFormat.bonusesBounceBack ? 0 : undefined,\r\n lineups: [firstLineup],\r\n match_players: [],\r\n team,\r\n });\r\n }\r\n }\r\n\r\n for (const player of game.players) {\r\n const qbjPlayer: IPlayer = {\r\n name: player.name,\r\n };\r\n\r\n players.push(qbjPlayer);\r\n\r\n const teamPlayerList: IPlayer[] | undefined = teamPlayers.get(player.teamName);\r\n if (teamPlayerList) {\r\n teamPlayerList.push(qbjPlayer);\r\n }\r\n\r\n if (player.isStarter) {\r\n const lineup: ILineup | undefined = teamLineups.get(player.teamName);\r\n if (lineup) {\r\n lineup.players.push(qbjPlayer);\r\n }\r\n }\r\n\r\n const matchTeam: IMatchTeam | undefined = matchTeams.get(player.teamName);\r\n if (matchTeam) {\r\n matchTeam.match_players.push({\r\n player: qbjPlayer,\r\n answer_counts: [],\r\n tossups_heard: 0,\r\n });\r\n matchTeam.team.players.push(qbjPlayer);\r\n }\r\n }\r\n\r\n const matchQuestions: IMatchQuestion[] = [];\r\n let tossupNumber = 1;\r\n const teamChangesInCycle: Set = new Set();\r\n\r\n // TODO: Loop until the end of the game, not the number of cycles\r\n for (let i = 0; i < game.playableCycles.length; i++) {\r\n const cycle: Cycle = game.playableCycles[i];\r\n // Seems like this will have a lot of overlap with CycleItemList\r\n\r\n // Ordering of events is\r\n // Substitutions\r\n // Buzzes and thrown out tossups, based on the tossup index. If a thrown out tossup and buzz have the same index,\r\n // prefer the buzz.\r\n // Thrown out bonuses\r\n // Bonus Answer\r\n // TU protests\r\n // Bonus protests\r\n\r\n // If there's any change in players, we need to update the lineup. We should gather all changes at once, since\r\n // it only cares about the lineup at a certain time\r\n if (cycle.playerLeaves || cycle.playerJoins || cycle.subs) {\r\n teamChangesInCycle.clear();\r\n\r\n if (cycle.playerLeaves) {\r\n for (const leave of cycle.playerLeaves) {\r\n const lineup: ILineup | undefined = teamLineups.get(leave.outPlayer.teamName);\r\n if (lineup) {\r\n const newLineup: ILineup = {\r\n first_question: i + 1,\r\n players: lineup.players.filter((player) => player.name !== leave.outPlayer.name),\r\n };\r\n\r\n teamLineups.set(leave.outPlayer.teamName, newLineup);\r\n teamChangesInCycle.add(leave.outPlayer.teamName);\r\n }\r\n }\r\n }\r\n\r\n if (cycle.playerJoins) {\r\n for (const join of cycle.playerJoins) {\r\n const lineup: ILineup | undefined = teamLineups.get(join.inPlayer.teamName);\r\n if (lineup) {\r\n const newPlayer: IPlayer = { name: join.inPlayer.name };\r\n const newLineup: ILineup = {\r\n first_question: i + 1,\r\n players: lineup.players.concat(newPlayer),\r\n };\r\n\r\n teamLineups.set(join.inPlayer.teamName, newLineup);\r\n teamChangesInCycle.add(join.inPlayer.teamName);\r\n\r\n const matchTeam: IMatchTeam | undefined = matchTeams.get(join.inPlayer.teamName);\r\n if (matchTeam != undefined) {\r\n const newMatchPlayer: IMatchPlayer = {\r\n answer_counts: [],\r\n player: newPlayer,\r\n tossups_heard: 0,\r\n };\r\n matchTeam.match_players.push(newMatchPlayer);\r\n }\r\n }\r\n }\r\n }\r\n\r\n if (cycle.subs) {\r\n for (const sub of cycle.subs) {\r\n const lineup: ILineup | undefined = teamLineups.get(sub.inPlayer.teamName);\r\n if (lineup) {\r\n const newLineup: ILineup = {\r\n first_question: i + 1,\r\n players: lineup.players\r\n .filter((player) => player.name !== sub.outPlayer.name)\r\n .concat({ name: sub.inPlayer.name }),\r\n };\r\n\r\n teamLineups.set(sub.inPlayer.teamName, newLineup);\r\n teamChangesInCycle.add(sub.inPlayer.teamName);\r\n }\r\n }\r\n }\r\n\r\n for (const teamName of teamChangesInCycle.values()) {\r\n const matchTeam: IMatchTeam | undefined = matchTeams.get(teamName);\r\n const newLineup: ILineup | undefined = teamLineups.get(teamName);\r\n if (matchTeam != undefined && newLineup != undefined) {\r\n matchTeam.lineups.push(newLineup);\r\n }\r\n }\r\n }\r\n\r\n // Update the TUH of all the players after we've calculated this cycle's lineup\r\n // We could do this later based on the lineups in the matchTeam, but this way is much easier to calculate\r\n // The number of players on a team and in the lineups should be small, so this quadratic approach should be\r\n // fine (and likely faster than using a map each time)\r\n for (const matchTeam of matchTeams.values()) {\r\n const lineup: ILineup | undefined = teamLineups.get(matchTeam.team.name);\r\n if (lineup) {\r\n for (const player of matchTeam.match_players) {\r\n if (lineup.players.some((p) => p.name === player.player.name)) {\r\n player.tossups_heard++;\r\n }\r\n }\r\n }\r\n }\r\n\r\n let replacementTossup: IQuestion | undefined = undefined;\r\n if (cycle.thrownOutTossups) {\r\n for (const thrownOutTossup of cycle.thrownOutTossups) {\r\n noteworthyEvents.push(`Tossup thrown out on question ${thrownOutTossup.questionIndex + 1}`);\r\n tossupNumber++;\r\n replacementTossup = {\r\n parts: 1,\r\n question_number: tossupNumber,\r\n type: \"tossup\",\r\n };\r\n }\r\n }\r\n\r\n if (cycle.thrownOutBonuses) {\r\n for (const thrownOutBonus of cycle.thrownOutBonuses) {\r\n // TODO: Unclear on how thrown out bonuses should be handled, since the replacement_bonus is just the\r\n // bonus right now. Just add an event for now\r\n noteworthyEvents.push(`Bonus thrown out on question ${thrownOutBonus.questionIndex + 1}`);\r\n }\r\n }\r\n\r\n // We have to track tu/bonus question numbers\r\n const matchQuestion: IMatchQuestion = {\r\n question_number: i + 1,\r\n buzzes: [],\r\n tossup_question: {\r\n parts: 1,\r\n type: \"tossup\",\r\n question_number: tossupNumber,\r\n },\r\n replacement_tossup_question: replacementTossup,\r\n // TODO: Figure out how to set replacement_bonus. Doesn't really make sense right now, since it seems to be\r\n // the same as bonus\r\n bonus: undefined,\r\n };\r\n\r\n let isFirstBuzz = true;\r\n for (const buzz of cycle.orderedBuzzes) {\r\n const matchBuzz: IMatchQuestionBuzz | undefined = getBuzz(game, teams, buzz, isFirstBuzz);\r\n if (matchBuzz != undefined) {\r\n matchQuestion.buzzes.push(matchBuzz);\r\n updateAnswerCount(matchTeams, matchBuzz);\r\n }\r\n\r\n isFirstBuzz = false;\r\n }\r\n\r\n if (cycle.correctBuzz && cycle.bonusAnswer) {\r\n const matchTeam: IMatchTeam | undefined = matchTeams.get(cycle.bonusAnswer.receivingTeamName);\r\n const otherTeam: IMatchTeam | undefined = [...matchTeams.values()].find((team) => team !== matchTeam);\r\n\r\n const parts: IMatchQuestionBonusPart[] = [];\r\n for (let j = 0; j < cycle.bonusAnswer.parts.length; j++) {\r\n const bonusAnswerPart: IBonusAnswerPart | undefined =\r\n cycle.bonusAnswer.parts && cycle.bonusAnswer.parts[j];\r\n const points: number = bonusAnswerPart ? bonusAnswerPart.points : 0;\r\n const matchPart: IMatchQuestionBonusPart = {\r\n controlled_points: 0,\r\n };\r\n\r\n if (\r\n matchTeam != undefined &&\r\n (bonusAnswerPart == undefined ||\r\n bonusAnswerPart.teamName === cycle.correctBuzz.marker.player.teamName)\r\n ) {\r\n matchPart.controlled_points = points;\r\n matchPart.bounceback_points = game.gameFormat.bonusesBounceBack ? 0 : undefined;\r\n matchTeam.bonus_points += points;\r\n } else if (otherTeam != undefined) {\r\n matchPart.bounceback_points = points;\r\n if (otherTeam.bonus_bounceback_points != undefined) {\r\n otherTeam.bonus_bounceback_points += points;\r\n }\r\n }\r\n\r\n parts.push(matchPart);\r\n }\r\n\r\n const matchBonus: IMatchQuestionBonus = {\r\n question: {\r\n parts: cycle.bonusAnswer.parts.length,\r\n type: \"bonus\",\r\n question_number: cycle.bonusAnswer.bonusIndex + 1,\r\n },\r\n parts,\r\n };\r\n matchQuestion.bonus = matchBonus;\r\n }\r\n\r\n if (cycle.tossupProtests) {\r\n for (const protest of cycle.tossupProtests) {\r\n noteworthyEvents.push(\r\n `Tossup protest on question ${protest.questionIndex + 1}. Team \"${\r\n protest.teamName\r\n }\" protested because of this reason: \"${protest.reason}\".`\r\n );\r\n }\r\n }\r\n\r\n if (cycle.bonusProtests) {\r\n for (const protest of cycle.bonusProtests) {\r\n noteworthyEvents.push(\r\n `Bonus protest on question ${protest.questionIndex + 1}. Team \"${\r\n protest.teamName\r\n }\" protested part ${protest.partIndex + 1} because of this reason: \"${protest.reason}\".`\r\n );\r\n }\r\n }\r\n\r\n // Next cycle always begins with the next tossup\r\n matchQuestions.push(matchQuestion);\r\n tossupNumber++;\r\n }\r\n\r\n const match: IMatch = {\r\n // TODO: This should take the format into account, based on how long regular matches should be, plus overtimes\r\n tossups_read: game.playableCycles.length,\r\n match_teams: [...matchTeams.values()],\r\n match_questions: matchQuestions,\r\n notes: noteworthyEvents.length > 0 ? noteworthyEvents.join(\"\\n\") : undefined,\r\n _round: round,\r\n };\r\n\r\n if (packetName) {\r\n const lastDotIndex: number = packetName.lastIndexOf(\".\");\r\n if (lastDotIndex > 0) {\r\n // Strip out the . to get the packet name\r\n packetName = packetName.substring(0, lastDotIndex);\r\n }\r\n\r\n match.packets = packetName;\r\n }\r\n\r\n return match;\r\n}\r\n\r\nfunction getBuzz(\r\n game: GameState,\r\n teams: ITeam[],\r\n buzz: ITossupAnswerEvent,\r\n isFirstBuzz: boolean\r\n): IMatchQuestionBuzz | undefined {\r\n const team: ITeam | undefined = teams.find((team) => team.name === buzz.marker.player.teamName);\r\n\r\n // Negs only happen on the first incorrect buzz (for now), so reset the value to 0 if they were wrong\r\n let buzzPoints: number = game.getBuzzValue(buzz);\r\n if (buzzPoints === game.gameFormat.negValue && !isFirstBuzz) {\r\n // TODO: This should probably come from a game format setting. For now, if it's not the first wrong answer, it's\r\n // not a neg. Reset its value to 0.\r\n buzzPoints = 0;\r\n }\r\n\r\n return (\r\n team && {\r\n buzz_position: {\r\n word_index: buzz.marker.position,\r\n },\r\n player: { name: buzz.marker.player.name },\r\n team,\r\n result: { value: buzzPoints },\r\n }\r\n );\r\n}\r\n\r\nfunction updateAnswerCount(matchTeams: Map, buzz: IMatchQuestionBuzz): void {\r\n const matchTeam: IMatchTeam | undefined = matchTeams.get(buzz.team.name);\r\n\r\n if (matchTeam == undefined) {\r\n return;\r\n }\r\n\r\n const player: IMatchPlayer | undefined = matchTeam.match_players.find(\r\n (matchPlayer) => matchPlayer.player.name === buzz.player.name\r\n );\r\n\r\n if (player == undefined) {\r\n return;\r\n }\r\n\r\n const points: number = buzz.result.value;\r\n let answerCount: IPlayerAnswerCount | undefined = player.answer_counts.find(\r\n (answer) => answer.answer.value === points\r\n );\r\n if (answerCount == undefined) {\r\n answerCount = {\r\n answer: {\r\n value: points,\r\n },\r\n number: 0,\r\n };\r\n player.answer_counts.push(answerCount);\r\n }\r\n\r\n answerCount.number++;\r\n}\r\n\r\n// Adapted from https://schema.quizbowl.technology/match\r\nexport interface IMatch {\r\n tossups_read: number;\r\n overtime_tossups_read?: number; //(leave empty for now, until formats are more integrated)\r\n match_teams: IMatchTeam[];\r\n match_questions: IMatchQuestion[];\r\n notes?: string; // For storing protest info and thrown out Qs\r\n packets?: string; // The name of the packet\r\n\r\n _round?: number; // This isn't in the QBJ spec, but is useful for MODAQ since some of its use cases are for reading one game at a time\r\n}\r\n\r\nexport interface ITeam {\r\n name: string;\r\n players: IPlayer[];\r\n}\r\n\r\nexport interface IPlayer {\r\n name: string;\r\n}\r\n\r\nexport interface IMatchTeam {\r\n team: ITeam;\r\n bonus_points: number;\r\n bonus_bounceback_points?: number;\r\n match_players: IMatchPlayer[];\r\n lineups: ILineup[]; // Lineups seen. New entries happen when there are changes in the lineup\r\n}\r\n\r\nexport interface IMatchPlayer {\r\n player: IPlayer;\r\n tossups_heard: number;\r\n answer_counts: IPlayerAnswerCount[];\r\n}\r\n\r\nexport interface IPlayerAnswerCount {\r\n number: number;\r\n answer: IAnswerType;\r\n}\r\n\r\nexport interface ILineup {\r\n first_question: number; // Which question number this lineup heard first\r\n players: IPlayer[];\r\n // could eventually do reason if we have formats restrict when subs occur\r\n}\r\n\r\nexport interface IAnswerType {\r\n value: number; // # of points\r\n // Could include label for neg/no penalty/get/power/etc.\r\n}\r\n\r\nexport interface IMatchQuestion {\r\n question_number: number; // The cycle, starts at 1\r\n tossup_question: IQuestion;\r\n replacement_tossup_question?: IQuestion; // multiple replacement tossups not currently supported\r\n buzzes: IMatchQuestionBuzz[];\r\n bonus?: IMatchQuestionBonus;\r\n replacement_bonus?: IMatchQuestionBonus; // multiple replacements not currently supported\r\n}\r\n\r\nexport interface IQuestion {\r\n question_number: number; // number of question in packet\r\n type: \"tossup\" | \"bonus\" | \"lightning\";\r\n parts: number; // 1 for tossup, n for bonuses\r\n}\r\n\r\nexport interface IMatchQuestionBuzz {\r\n team: ITeam;\r\n player: IPlayer;\r\n buzz_position: IBuzzPosition;\r\n result: IAnswerType;\r\n}\r\n\r\nexport interface IBuzzPosition {\r\n word_index: number; // 0-indexed\r\n}\r\n\r\nexport interface IMatchQuestionBonus {\r\n question?: IQuestion;\r\n parts: IMatchQuestionBonusPart[];\r\n}\r\n\r\nexport interface IMatchQuestionBonusPart {\r\n controlled_points: number;\r\n bounceback_points?: number;\r\n}\r\n\r\n// Follow https://schema.quizbowl.technology/tournament\r\nexport interface ITournament {\r\n name: string;\r\n registrations: IRegistration[];\r\n}\r\n\r\nexport interface IRegistration {\r\n name: string;\r\n teams: ITeam[];\r\n}\r\n","import { toJS } from \"mobx\";\r\nimport { IStatus } from \"../IStatus\";\r\nimport { IMatch } from \"../qbj/QBJ\";\r\n\r\nimport { ICycle } from \"./Cycle\";\r\nimport { GameState } from \"./GameState\";\r\nimport { IPacket } from \"./IPacket\";\r\nimport { IPlayer } from \"./TeamState\";\r\n\r\nexport function convertGameToExportFields(game: GameState): IExportFields {\r\n return {\r\n cycles: toJS(game.cycles),\r\n players: toJS(game.players),\r\n packet: {\r\n tossups: game.packet.tossups.map((tossup, index) => {\r\n return toJS({\r\n answer: tossup.answer,\r\n question: tossup.question,\r\n number: index + 1,\r\n });\r\n }),\r\n bonuses: game.packet.bonuses?.map((bonus, index) => {\r\n return {\r\n leadin: bonus.leadin,\r\n answers: bonus.parts.map((part) => part.answer),\r\n number: index + 1,\r\n parts: bonus.parts.map((part) => part.question),\r\n values: bonus.parts.map((part) => part.value),\r\n difficultyModifiers: bonus.parts.every((part) => part.difficultyModifier != undefined)\r\n ? bonus.parts.map((part) => part.difficultyModifier as string)\r\n : undefined,\r\n };\r\n }),\r\n },\r\n };\r\n}\r\n\r\nexport type ICustomExport = ICustomRawExport | ICustomQBJExport;\r\n\r\nexport interface IExportFields {\r\n /**\r\n * The cycles for the current game. Each element represents a tossup/bonus cycle in the game, and stores all events\r\n * that occurred in that cycle.\r\n */\r\n cycles: ICycle[];\r\n\r\n /**\r\n * The players in the current game. This has players from every team. Team order isn't guaranteed.\r\n */\r\n players: IPlayer[];\r\n\r\n /**\r\n * The packet used in the current game\r\n */\r\n packet: IPacket;\r\n}\r\n\r\nexport interface IExportContext {\r\n /**\r\n * How the export was created. It could be exported from the export menu item, from the export prompt when saving\r\n * a new game, from the export prompt when clicking on Next on the last tossup, or from a timer event.\r\n */\r\n source: ExportSource;\r\n}\r\n\r\ninterface ICustomRawExport extends IBaseCustomExport {\r\n /**\r\n * Callback for exporting the game\r\n * @param fields All the fields needed to represent the current game\r\n * @param context The context for the export, such as how the export was started\r\n * @returns An `IStatus` indicating if the export was successful\r\n */\r\n onExport: (fields: IExportFields, context?: IExportContext) => Promise;\r\n type: \"Raw\";\r\n}\r\n\r\ninterface ICustomQBJExport extends IBaseCustomExport {\r\n /**\r\n * Callback for exporting the game\r\n * @param qbj QBJ Match of the current game\r\n * @param context The context for the export, such as how the export was started\r\n * @returns An `IStatus` indicating if the export was successful\r\n */\r\n onExport: (qbj: IMatch, context?: IExportContext) => Promise;\r\n type: \"QBJ\";\r\n}\r\n\r\ninterface IBaseCustomExport {\r\n /**\r\n * Label text of the export button in the menu\r\n */\r\n label: string;\r\n\r\n /**\r\n * If defined, how often the customExport handler should be called in milliseconds. Setting this to null or undefined\r\n * will stop calling the customExport handler automatically.\r\n * The smallest interval allowed is 5000 milliseconds.\r\n */\r\n customExportInterval?: number;\r\n}\r\n\r\nexport type ExportType = \"Raw\" | \"QBJ\";\r\n\r\nexport type ExportSource = \"Menu\" | \"NewGame\" | \"NextButton\" | \"Timer\";\r\n","import { Cycle } from \"./Cycle\";\r\nimport { IGameFormat } from \"./IGameFormat\";\r\nimport { PacketState } from \"./PacketState\";\r\nimport { Player } from \"./TeamState\";\r\n\r\nexport type IPendingNewGame =\r\n | IPendingManualNewGame\r\n | IPendingFromTJSheetsNewGame\r\n | IPendingFromUCSDSheetsNewGame\r\n | IPendingQBJRegistrationNewGame;\r\n\r\nexport interface IPendingManualNewGame extends IBasePendingNewGame {\r\n manual: IPendingManualNewGameState;\r\n type: PendingGameType.Manual;\r\n}\r\n\r\nexport interface IPendingFromTJSheetsNewGame extends IBasePendingNewGame {\r\n tjSheets: IPendingFromSheetsNewGameState;\r\n type: PendingGameType.TJSheets;\r\n}\r\n\r\nexport interface IPendingFromUCSDSheetsNewGame extends IBasePendingNewGame {\r\n ucsdSheets: IPendingFromSheetsNewGameState;\r\n type: PendingGameType.UCSDSheets;\r\n}\r\n\r\nexport interface IPendingQBJRegistrationNewGame extends IBasePendingNewGame {\r\n registration: IPendingQBJRegistrationNewGameState;\r\n type: PendingGameType.QBJRegistration;\r\n}\r\n\r\nexport interface IPendingManualNewGameState {\r\n firstTeamPlayers: Player[];\r\n secondTeamPlayers: Player[];\r\n cycles?: Cycle[];\r\n}\r\n\r\nexport const enum PendingGameType {\r\n Manual,\r\n TJSheets,\r\n UCSDSheets,\r\n QBJRegistration,\r\n}\r\n\r\nexport interface IPendingFromSheetsNewGameState {\r\n rostersUrl: string | undefined;\r\n playersFromRosters: Player[] | undefined;\r\n firstTeamPlayersFromRosters: Player[] | undefined;\r\n secondTeamPlayersFromRosters: Player[] | undefined;\r\n}\r\n\r\nexport interface IPendingQBJRegistrationNewGameState {\r\n players: Player[];\r\n firstTeamPlayers: Player[] | undefined;\r\n secondTeamPlayers: Player[] | undefined;\r\n cycles?: Cycle[];\r\n errorMessage?: string;\r\n}\r\n\r\ninterface IBasePendingNewGame {\r\n packet: PacketState;\r\n gameFormat: IGameFormat;\r\n type: PendingGameType;\r\n}\r\n","import { makeAutoObservable } from \"mobx\";\r\nimport { ignore } from \"mobx-sync\";\r\n\r\nimport { IStatus } from \"../IStatus\";\r\n\r\nexport class SheetState {\r\n @ignore\r\n apiInitialized: LoadingState;\r\n\r\n public clientId: string | undefined;\r\n\r\n @ignore\r\n public exportStatus: IStatus | undefined;\r\n\r\n @ignore\r\n public exportState: ExportState | undefined;\r\n\r\n @ignore\r\n public rosterLoadStatus: IStatus | undefined;\r\n\r\n @ignore\r\n public rosterLoadState: LoadingState | undefined;\r\n\r\n // We need to remember sheetId, since we only input it when creating the game\r\n public sheetId?: string;\r\n\r\n // We want to remember the round number, so we can keep exporting to the same round\r\n public roundNumber?: number;\r\n\r\n // We want to remember the sheet type so we can auto-fill it when we export\r\n public sheetType?: SheetType;\r\n\r\n constructor() {\r\n makeAutoObservable(this);\r\n\r\n this.apiInitialized = LoadingState.Unloaded;\r\n this.clientId = undefined;\r\n this.exportStatus = undefined;\r\n this.exportState = undefined;\r\n this.rosterLoadStatus = undefined;\r\n this.rosterLoadState = undefined;\r\n this.roundNumber = undefined;\r\n this.sheetId = undefined;\r\n this.sheetType = undefined;\r\n }\r\n\r\n public clearExportStatus(): void {\r\n this.exportStatus = undefined;\r\n this.exportState = undefined;\r\n }\r\n\r\n public clearRoundNumber(): void {\r\n this.roundNumber = undefined;\r\n }\r\n\r\n public clearSheetType(): void {\r\n this.sheetType = undefined;\r\n }\r\n\r\n public setClientId(clientId: string | undefined): void {\r\n this.clientId = clientId;\r\n }\r\n\r\n public setExportStatus(status: IStatus, state: ExportState | undefined = undefined): void {\r\n this.exportStatus = status;\r\n\r\n if (state != undefined) {\r\n this.exportState = state;\r\n }\r\n }\r\n\r\n public setRosterLoadStatus(status: IStatus, state: LoadingState | undefined = undefined): void {\r\n this.rosterLoadStatus = status;\r\n\r\n if (state != undefined) {\r\n this.rosterLoadState = state;\r\n }\r\n }\r\n\r\n public setSheetsApiInitialized(state: LoadingState): void {\r\n this.apiInitialized = state;\r\n }\r\n\r\n public setSheetId(sheetId: string): void {\r\n this.sheetId = sheetId;\r\n }\r\n\r\n public setSheetType(sheetType: SheetType): void {\r\n this.sheetType = sheetType;\r\n }\r\n\r\n public setRoundNumber(roundNumber: number): void {\r\n this.roundNumber = roundNumber;\r\n }\r\n}\r\n\r\nexport const enum LoadingState {\r\n Unloaded = 0,\r\n Loading = 1,\r\n Loaded = 2,\r\n Error = 3,\r\n}\r\n\r\nexport const enum ExportState {\r\n CheckingOvewrite = 0,\r\n OverwritePrompt = 1,\r\n Exporting = 2,\r\n Success = 3,\r\n Error = 4,\r\n}\r\n\r\nexport const enum SheetType {\r\n /**\r\n * DEPRECATED\r\n */\r\n Lifsheets = 0,\r\n TJSheets = 1,\r\n UCSDSheets = 2,\r\n}\r\n","import { makeAutoObservable } from \"mobx\";\r\n\r\nimport { PacketState } from \"./PacketState\";\r\n\r\nexport class AddQuestionDialogState {\r\n public newPacket: PacketState;\r\n\r\n constructor() {\r\n makeAutoObservable(this);\r\n\r\n this.newPacket = new PacketState();\r\n }\r\n\r\n public setPacket(packet: PacketState): void {\r\n this.newPacket = packet;\r\n }\r\n}\r\n","import { makeAutoObservable } from \"mobx\";\r\nimport { IGameFormat } from \"./IGameFormat\";\r\n\r\nexport class CustomizeGameFormatDialogState {\r\n public gameFormat: IGameFormat;\r\n\r\n // Based on the UI, we have to store powerMarkers/powerValues separately. This is a bit of a leaky abstraction\r\n // (state shouldn't know about the view), which we should try to fix\r\n public powerMarkers: string[];\r\n\r\n public powerValues: string;\r\n\r\n public pronunicationGuideMarkers: string[] | undefined;\r\n\r\n public powerMarkerErrorMessage: string | undefined;\r\n\r\n public powerValuesErrorMessage: string | undefined;\r\n\r\n public pronunciationGuideMarkersErrorMessage: string | undefined;\r\n\r\n constructor(existingGameFormat: IGameFormat) {\r\n makeAutoObservable(this);\r\n\r\n this.gameFormat = { ...existingGameFormat };\r\n this.powerMarkers = this.gameFormat.powers.map((power) => power.marker);\r\n this.powerMarkerErrorMessage = undefined;\r\n\r\n this.powerValues = this.gameFormat.powers.map((power) => power.points).join(\",\");\r\n this.powerValuesErrorMessage = undefined;\r\n\r\n this.pronunicationGuideMarkers = this.gameFormat.pronunciationGuideMarkers;\r\n this.pronunciationGuideMarkersErrorMessage = undefined;\r\n }\r\n\r\n public clearPowerErrorMessages(): void {\r\n this.powerMarkerErrorMessage = undefined;\r\n this.powerValuesErrorMessage = undefined;\r\n }\r\n\r\n public clearPronunciationGuideMarkersErrorMessage(): void {\r\n this.pronunciationGuideMarkersErrorMessage = undefined;\r\n }\r\n\r\n public setPowerMarkers(powerMarkers: string[]): void {\r\n // Clear the error message if we have a new value\r\n this.powerMarkers = powerMarkers;\r\n this.powerMarkerErrorMessage = undefined;\r\n }\r\n\r\n public setPowerMarkerErrorMessage(message: string): void {\r\n this.powerMarkerErrorMessage = message;\r\n }\r\n\r\n public setPowerValues(powerValues: string): void {\r\n // Clear the error message if we have a new value\r\n this.powerValues = powerValues;\r\n this.powerValuesErrorMessage = undefined;\r\n }\r\n\r\n public setPowerValuesErrorMessage(message: string): void {\r\n this.powerValuesErrorMessage = message;\r\n }\r\n\r\n public setPronunciationGuideMarkers(pronunciationGuideMarkers: string[]): void {\r\n this.pronunicationGuideMarkers = pronunciationGuideMarkers;\r\n this.clearPronunciationGuideMarkersErrorMessage();\r\n }\r\n\r\n public setPronunciationGuideMarkersErrorMessage(message: string): void {\r\n this.pronunciationGuideMarkersErrorMessage = message;\r\n }\r\n\r\n public updateGameFormat(gameFormatUpdate: Partial): void {\r\n this.gameFormat = { ...this.gameFormat, ...gameFormatUpdate };\r\n\r\n if (gameFormatUpdate.powers != undefined) {\r\n this.setPowerValues(this.gameFormat.powers.map((power) => power.points).join(\",\"));\r\n this.setPowerMarkers(this.gameFormat.powers.map((power) => power.marker));\r\n }\r\n\r\n if (gameFormatUpdate.pronunciationGuideMarkers != undefined) {\r\n this.pronunciationGuideMarkersErrorMessage = undefined;\r\n this.pronunicationGuideMarkers = this.gameFormat.pronunciationGuideMarkers;\r\n }\r\n }\r\n}\r\n","export interface IMessageDialogState {\r\n title: string;\r\n message: string;\r\n type: MessageDialogType;\r\n onOK?: () => void;\r\n onNo?: () => void;\r\n}\r\n\r\nexport const enum MessageDialogType {\r\n OK = 0,\r\n OKCancel = 1,\r\n YesNocCancel = 2,\r\n}\r\n","import { makeAutoObservable } from \"mobx\";\r\nimport { Player } from \"./TeamState\";\r\n\r\nexport class RenamePlayerDialogState {\r\n public errorMessage: string | undefined;\r\n\r\n public newName: string;\r\n\r\n public player: Player;\r\n\r\n constructor(player: Player) {\r\n makeAutoObservable(this);\r\n\r\n this.errorMessage = undefined;\r\n this.newName = \"\";\r\n this.player = player;\r\n }\r\n\r\n public clearErrorMessage(): void {\r\n this.errorMessage = undefined;\r\n }\r\n\r\n public setErrorMessage(errorMessage: string): void {\r\n this.errorMessage = errorMessage;\r\n }\r\n\r\n public setName(newName: string): void {\r\n this.newName = newName;\r\n }\r\n\r\n public setPlayer(player: Player): void {\r\n this.player = player;\r\n }\r\n}\r\n","import { makeAutoObservable } from \"mobx\";\r\n\r\nimport * as PlayerUtils from \"./PlayerUtils\";\r\nimport { Player } from \"./TeamState\";\r\n\r\nexport class ReorderPlayersDialogState {\r\n public teamName: string;\r\n\r\n public players: Player[];\r\n\r\n constructor(players: Player[]) {\r\n makeAutoObservable(this);\r\n\r\n this.players = players;\r\n this.teamName = players.length > 0 ? players[0].teamName : \"\";\r\n }\r\n\r\n public setTeamName(newTeam: string): void {\r\n this.teamName = newTeam;\r\n }\r\n\r\n public movePlayerBackward(player: Player): void {\r\n this.players = PlayerUtils.movePlayerBackward(this.players, player);\r\n }\r\n\r\n public movePlayerForward(player: Player): void {\r\n this.players = PlayerUtils.movePlayerForward(this.players, player);\r\n }\r\n\r\n public movePlayerToIndex(player: Player, index: number): void {\r\n this.players = PlayerUtils.movePlayerToIndex(this.players, player, index);\r\n }\r\n}\r\n","import { makeAutoObservable } from \"mobx\";\r\nimport { ignore } from \"mobx-sync\";\r\n\r\nexport const DefaultFontFamily =\r\n \"Segoe UI, Times New Roman, -apple-system, BlinkMacSystemFont, Roboto, Helvetica Neue, serif\";\r\n\r\nexport class FontDialogState {\r\n @ignore\r\n public fontFamily: string | undefined;\r\n\r\n @ignore\r\n public fontSize: number | undefined;\r\n\r\n @ignore\r\n public pronunciationGuideColor: string | undefined;\r\n\r\n @ignore\r\n public textColor: string | undefined;\r\n\r\n constructor(\r\n fontFamily: string | undefined,\r\n fontSize: number | undefined,\r\n textColor: string | undefined,\r\n pronunciationGuideColor: string | undefined\r\n ) {\r\n makeAutoObservable(this);\r\n\r\n this.fontFamily = fontFamily;\r\n this.fontSize = fontSize;\r\n this.pronunciationGuideColor = pronunciationGuideColor;\r\n this.textColor = textColor;\r\n }\r\n\r\n public resetPronunciationGuideColor(): void {\r\n this.pronunciationGuideColor = undefined;\r\n }\r\n\r\n public resetTextColor(): void {\r\n this.textColor = undefined;\r\n }\r\n\r\n public setFontFamily(listedFont: string): void {\r\n this.fontFamily = listedFont + \", \" + DefaultFontFamily;\r\n }\r\n\r\n public setFontSize(fontSize: number): void {\r\n this.fontSize = fontSize;\r\n }\r\n\r\n public setPronunciationGuideColor(color: string): void {\r\n this.pronunciationGuideColor = color;\r\n }\r\n\r\n public setTextColor(color: string): void {\r\n this.textColor = color;\r\n }\r\n}\r\n","export const enum ModalVisibilityStatus {\r\n None,\r\n AddPlayer,\r\n AddQuestions,\r\n BonusProtest,\r\n CustomizeGameFormat,\r\n ExportToJson,\r\n ExportToSheets,\r\n Font,\r\n Help,\r\n ImportGame,\r\n Message,\r\n NewGame,\r\n RenamePlayer,\r\n RenameTeam,\r\n ReorderPlayers,\r\n Scoresheet,\r\n TossupProtest,\r\n}\r\n","import { makeAutoObservable } from \"mobx\";\r\n\r\nexport class RenameTeamDialogState {\r\n public errorMessage: string | undefined;\r\n\r\n public newName: string;\r\n\r\n public teamName: string;\r\n\r\n constructor(initialTeamName: string) {\r\n makeAutoObservable(this);\r\n\r\n this.errorMessage = undefined;\r\n this.newName = initialTeamName;\r\n this.teamName = initialTeamName;\r\n }\r\n\r\n public clearErrorMessage(): void {\r\n this.errorMessage = undefined;\r\n }\r\n\r\n public setErrorMessage(errorMessage: string): void {\r\n this.errorMessage = errorMessage;\r\n }\r\n\r\n public setName(newName: string): void {\r\n this.newName = newName;\r\n }\r\n\r\n public setTeam(teamName: string): void {\r\n this.teamName = teamName;\r\n }\r\n}\r\n","import { makeAutoObservable } from \"mobx\";\r\nimport { ignore } from \"mobx-sync\";\r\nimport { AddQuestionDialogState } from \"./AddQuestionsDialogState\";\r\nimport { CustomizeGameFormatDialogState } from \"./CustomizeGameFormatDialogState\";\r\nimport { IGameFormat } from \"./IGameFormat\";\r\nimport { IMessageDialogState, MessageDialogType } from \"./IMessageDialogState\";\r\nimport { RenamePlayerDialogState } from \"./RenamePlayerDialogState\";\r\nimport { Player } from \"./TeamState\";\r\nimport { ReorderPlayersDialogState } from \"./ReorderPlayersDialogState\";\r\nimport { FontDialogState } from \"./FontDialogState\";\r\nimport { ModalVisibilityStatus } from \"./ModalVisibilityStatus\";\r\nimport { RenameTeamDialogState } from \"./RenameTeamDialogState\";\r\n\r\nexport class DialogState {\r\n @ignore\r\n public addQuestions: AddQuestionDialogState | undefined;\r\n\r\n @ignore\r\n public customizeGameFormat: CustomizeGameFormatDialogState | undefined;\r\n\r\n @ignore\r\n public fontDialog: FontDialogState | undefined;\r\n\r\n @ignore\r\n public messageDialog: IMessageDialogState | undefined;\r\n\r\n @ignore\r\n public renamePlayerDialog: RenamePlayerDialogState | undefined;\r\n\r\n @ignore\r\n public renameTeamDialog: RenameTeamDialogState | undefined;\r\n\r\n @ignore\r\n public reorderPlayersDialog: ReorderPlayersDialogState | undefined;\r\n\r\n @ignore\r\n public visibleDialog: ModalVisibilityStatus;\r\n\r\n constructor() {\r\n makeAutoObservable(this);\r\n\r\n this.addQuestions = undefined;\r\n this.customizeGameFormat = undefined;\r\n this.fontDialog = undefined;\r\n this.messageDialog = undefined;\r\n this.renamePlayerDialog = undefined;\r\n this.renameTeamDialog = undefined;\r\n this.reorderPlayersDialog = undefined;\r\n this.visibleDialog = ModalVisibilityStatus.None;\r\n }\r\n\r\n public hideAddQuestionsDialog(): void {\r\n this.addQuestions = undefined;\r\n if (this.visibleDialog === ModalVisibilityStatus.AddQuestions) {\r\n this.hideModalDialog();\r\n }\r\n }\r\n\r\n public hideCustomizeGameFormatDialog(): void {\r\n this.customizeGameFormat = undefined;\r\n if (this.visibleDialog === ModalVisibilityStatus.CustomizeGameFormat) {\r\n this.hideModalDialog();\r\n }\r\n }\r\n\r\n public hideModalDialog(): void {\r\n this.visibleDialog = ModalVisibilityStatus.None;\r\n }\r\n\r\n public hideFontDialog(): void {\r\n this.fontDialog = undefined;\r\n if (this.visibleDialog === ModalVisibilityStatus.Font) {\r\n this.hideModalDialog();\r\n }\r\n }\r\n\r\n public hideMessageDialog(): void {\r\n this.messageDialog = undefined;\r\n if (this.visibleDialog === ModalVisibilityStatus.Message) {\r\n this.hideModalDialog();\r\n }\r\n }\r\n\r\n public hideRenamePlayerDialog(): void {\r\n this.renamePlayerDialog = undefined;\r\n if (this.visibleDialog === ModalVisibilityStatus.RenamePlayer) {\r\n this.hideModalDialog();\r\n }\r\n }\r\n\r\n public hideRenameTeamDialog(): void {\r\n this.renameTeamDialog = undefined;\r\n if (this.visibleDialog === ModalVisibilityStatus.RenameTeam) {\r\n this.hideModalDialog();\r\n }\r\n }\r\n\r\n public hideReorderPlayersDialog(): void {\r\n this.reorderPlayersDialog = undefined;\r\n if (this.visibleDialog === ModalVisibilityStatus.ReorderPlayers) {\r\n this.hideModalDialog();\r\n }\r\n }\r\n\r\n public showAddQuestionsDialog(): void {\r\n this.addQuestions = new AddQuestionDialogState();\r\n this.visibleDialog = ModalVisibilityStatus.AddQuestions;\r\n }\r\n\r\n public showCustomizeGameFormatDialog(gameFormat: IGameFormat): void {\r\n this.customizeGameFormat = new CustomizeGameFormatDialogState(gameFormat);\r\n this.visibleDialog = ModalVisibilityStatus.CustomizeGameFormat;\r\n }\r\n\r\n public showExportToJsonDialog(): void {\r\n this.visibleDialog = ModalVisibilityStatus.ExportToJson;\r\n }\r\n\r\n public showFontDialog(\r\n existingFontFamily: string,\r\n existingFontSize: number,\r\n existingTextColor: string | undefined,\r\n existingPronunciationGuideColor: string | undefined\r\n ): void {\r\n this.fontDialog = new FontDialogState(\r\n existingFontFamily,\r\n existingFontSize,\r\n existingTextColor,\r\n existingPronunciationGuideColor\r\n );\r\n this.visibleDialog = ModalVisibilityStatus.Font;\r\n }\r\n\r\n public showHelpDialog(): void {\r\n this.visibleDialog = ModalVisibilityStatus.Help;\r\n }\r\n\r\n public showImportGameDialog(): void {\r\n this.visibleDialog = ModalVisibilityStatus.ImportGame;\r\n }\r\n\r\n public showRenamePlayerDialog(player: Player): void {\r\n this.renamePlayerDialog = new RenamePlayerDialogState(player);\r\n this.visibleDialog = ModalVisibilityStatus.RenamePlayer;\r\n }\r\n\r\n public showRenameTeamDialog(initialTeamName: string): void {\r\n this.renameTeamDialog = new RenameTeamDialogState(initialTeamName);\r\n this.visibleDialog = ModalVisibilityStatus.RenameTeam;\r\n }\r\n\r\n public showReorderPlayersDialog(players: Player[]): void {\r\n this.reorderPlayersDialog = new ReorderPlayersDialogState(players);\r\n this.visibleDialog = ModalVisibilityStatus.ReorderPlayers;\r\n }\r\n\r\n public showScoresheetDialog(): void {\r\n this.visibleDialog = ModalVisibilityStatus.Scoresheet;\r\n }\r\n\r\n public showOKMessageDialog(title: string, message: string, onOK?: () => void): void {\r\n this.messageDialog = {\r\n title,\r\n message,\r\n type: MessageDialogType.OK,\r\n onOK,\r\n };\r\n this.visibleDialog = ModalVisibilityStatus.Message;\r\n }\r\n\r\n public showOKCancelMessageDialog(title: string, message: string, onOK: () => void): void {\r\n this.messageDialog = {\r\n title,\r\n message,\r\n type: MessageDialogType.OKCancel,\r\n onOK,\r\n };\r\n this.visibleDialog = ModalVisibilityStatus.Message;\r\n }\r\n\r\n public showYesNoCancelMessageDialog(title: string, message: string, onYes: () => void, onNo: () => void): void {\r\n this.messageDialog = {\r\n title,\r\n message,\r\n type: MessageDialogType.YesNocCancel,\r\n onOK: onYes,\r\n onNo: onNo,\r\n };\r\n this.visibleDialog = ModalVisibilityStatus.Message;\r\n }\r\n\r\n public showNewGameDialog(): void {\r\n this.visibleDialog = ModalVisibilityStatus.NewGame;\r\n }\r\n}\r\n","import { assertNever } from \"@fluentui/react\";\r\nimport { makeAutoObservable } from \"mobx\";\r\nimport { ignore } from \"mobx-sync\";\r\n\r\nimport * as GameFormats from \"./GameFormats\";\r\nimport { ITossupProtestEvent, IBonusProtestEvent } from \"./Events\";\r\nimport {\r\n IPendingFromSheetsNewGameState,\r\n IPendingNewGame,\r\n IPendingQBJRegistrationNewGameState,\r\n PendingGameType,\r\n} from \"./IPendingNewGame\";\r\nimport { PacketState } from \"./PacketState\";\r\nimport { Player } from \"./TeamState\";\r\nimport { SheetState } from \"./SheetState\";\r\nimport { IStatus } from \"../IStatus\";\r\nimport { IPendingSheet } from \"./IPendingSheet\";\r\nimport { Cycle } from \"./Cycle\";\r\nimport { DialogState } from \"./DialogState\";\r\nimport { IGameFormat } from \"./IGameFormat\";\r\nimport { BuzzMenuState } from \"./BuzzMenuState\";\r\nimport { ICustomExport } from \"./CustomExport\";\r\nimport { ModalVisibilityStatus } from \"./ModalVisibilityStatus\";\r\n\r\n// TODO: Look into breaking this up into individual UI component states. Lots of pendingX fields, which could be in\r\n// their own (see CustomizeGameFormatDialogState)\r\n// Alternatively, keep certain component-local states in the component state, and only store values that could be used\r\n// outside of that component here.\r\n\r\nconst DefaultFontFamily = \"Segoe UI, Times New Roman, -apple-system, BlinkMacSystemFont, Roboto, Helvetica Neue, serif\";\r\n\r\nexport class UIState {\r\n @ignore\r\n public buildVersion: string | undefined;\r\n\r\n // TODO: Should we also include the Cycle? This would simplify anything that needs access to the cycle\r\n public cycleIndex: number;\r\n\r\n @ignore\r\n public dialogState: DialogState;\r\n\r\n public fontFamily: string;\r\n\r\n @ignore\r\n public isEditingCycleIndex: boolean;\r\n\r\n @ignore\r\n public selectedWordIndex: number;\r\n\r\n @ignore\r\n public buzzMenuState: BuzzMenuState;\r\n\r\n @ignore\r\n public customExportOptions: ICustomExport | undefined;\r\n\r\n @ignore\r\n public customExportIntervalId: number | undefined;\r\n\r\n @ignore\r\n public customExportStatus: string | undefined;\r\n\r\n @ignore\r\n public exportRoundNumber: number;\r\n\r\n // Default should be to always show bonuses. This setting didn't exist before, so use hide instead of show\r\n public hideBonusOnDeadTossup: boolean;\r\n\r\n @ignore\r\n public hideNewGame: boolean;\r\n\r\n @ignore\r\n public importGameStatus: IStatus | undefined;\r\n\r\n public packetFilename: string | undefined;\r\n\r\n @ignore\r\n public packetParseStatus: IStatus | undefined;\r\n\r\n @ignore\r\n public pendingBonusProtestEvent?: IBonusProtestEvent;\r\n\r\n @ignore\r\n public pendingNewGame?: IPendingNewGame;\r\n\r\n @ignore\r\n public pendingNewPlayer?: Player;\r\n\r\n @ignore\r\n public pendingSheet?: IPendingSheet;\r\n\r\n @ignore\r\n public pendingTossupProtestEvent?: ITossupProtestEvent;\r\n\r\n // Default should be to show the clock. This setting didn't exist before, so use hide instead of show\r\n public isClockHidden: boolean;\r\n\r\n // Default should be to show the event log. This setting didn't exist before, so use hide instead of show\r\n public isEventLogHidden: boolean;\r\n\r\n // Default should be to show the export status. This setting didn't exist before, so use hide instead of show\r\n public isCustomExportStatusHidden: boolean;\r\n\r\n // Default should be to have it horizontal.\r\n public isScoreVertical: boolean;\r\n\r\n // Default should be to highlight answered bonuses\r\n public noBonusHighlight: boolean;\r\n\r\n public pronunciationGuideColor: string | undefined;\r\n\r\n public questionFontColor: string | undefined;\r\n\r\n public questionFontSize: number;\r\n\r\n public sheetsState: SheetState;\r\n\r\n public useDarkMode: boolean;\r\n\r\n public yappServiceUrl: string | undefined;\r\n\r\n constructor() {\r\n makeAutoObservable(this);\r\n\r\n this.buildVersion = undefined;\r\n this.cycleIndex = 0;\r\n this.dialogState = new DialogState();\r\n this.isEditingCycleIndex = false;\r\n this.selectedWordIndex = -1;\r\n this.buzzMenuState = {\r\n clearSelectedWordOnClose: true,\r\n visible: false,\r\n };\r\n this.customExportOptions = undefined;\r\n this.customExportIntervalId = undefined;\r\n this.customExportStatus = undefined;\r\n this.exportRoundNumber = 1;\r\n this.hideBonusOnDeadTossup = false;\r\n this.hideNewGame = false;\r\n\r\n // Default to Fabric UI's default font (Segoe UI), then Times New Roman\r\n this.fontFamily = DefaultFontFamily;\r\n\r\n this.isClockHidden = false;\r\n this.isEventLogHidden = false;\r\n this.isCustomExportStatusHidden = false;\r\n this.isScoreVertical = false;\r\n this.importGameStatus = undefined;\r\n this.noBonusHighlight = false;\r\n this.packetFilename = undefined;\r\n this.packetParseStatus = undefined;\r\n this.pendingBonusProtestEvent = undefined;\r\n this.pendingNewGame = undefined;\r\n this.pendingNewPlayer = undefined;\r\n this.pendingSheet = undefined;\r\n this.pendingTossupProtestEvent = undefined;\r\n this.useDarkMode = false;\r\n this.yappServiceUrl = undefined;\r\n\r\n // These are defined by the theme if not set explicitly\r\n this.pronunciationGuideColor = undefined;\r\n this.questionFontColor = undefined;\r\n // The default font size is 16px\r\n this.questionFontSize = 16;\r\n this.sheetsState = new SheetState();\r\n }\r\n\r\n // TODO: Feels off. Could generalize to array of teams\r\n public addPlayerToFirstTeamInPendingNewGame(player: Player): void {\r\n if (this.pendingNewGame?.type === PendingGameType.Manual) {\r\n this.pendingNewGame.manual.firstTeamPlayers.push(player);\r\n }\r\n }\r\n\r\n public addPlayerToSecondTeamInPendingNewGame(player: Player): void {\r\n if (this.pendingNewGame?.type === PendingGameType.Manual) {\r\n this.pendingNewGame.manual.secondTeamPlayers.push(player);\r\n }\r\n }\r\n\r\n public clearPacketStatus(): void {\r\n this.packetParseStatus = undefined;\r\n }\r\n\r\n public clearPendingNewGameRegistrationStatus(): void {\r\n if (this.pendingNewGame?.type !== PendingGameType.QBJRegistration) {\r\n return;\r\n }\r\n\r\n this.pendingNewGame.registration.errorMessage = undefined;\r\n }\r\n\r\n public createPendingNewGame(): void {\r\n if (this.pendingNewGame == undefined) {\r\n const firstTeamPlayers: Player[] = [];\r\n const secondTeamPlayers: Player[] = [];\r\n for (let i = 0; i < 4; i++) {\r\n firstTeamPlayers.push(new Player(\"\", \"Team 1\", /* isStarter */ true));\r\n secondTeamPlayers.push(new Player(\"\", \"Team 2\", /* isStarter */ true));\r\n }\r\n\r\n this.pendingNewGame = {\r\n packet: new PacketState(),\r\n type: PendingGameType.Manual,\r\n gameFormat: GameFormats.ACFGameFormat,\r\n manual: {\r\n firstTeamPlayers,\r\n secondTeamPlayers,\r\n },\r\n };\r\n } else {\r\n this.pendingNewGame = {\r\n ...this.pendingNewGame,\r\n packet: new PacketState(),\r\n };\r\n\r\n switch (this.pendingNewGame.type) {\r\n case PendingGameType.Manual:\r\n this.pendingNewGame = {\r\n ...this.pendingNewGame,\r\n manual: {\r\n ...this.pendingNewGame.manual,\r\n cycles: undefined,\r\n },\r\n };\r\n\r\n break;\r\n case PendingGameType.QBJRegistration:\r\n this.pendingNewGame = {\r\n ...this.pendingNewGame,\r\n registration: {\r\n ...this.pendingNewGame.registration,\r\n cycles: undefined,\r\n },\r\n };\r\n break;\r\n case PendingGameType.TJSheets:\r\n this.pendingNewGame = {\r\n ...this.pendingNewGame,\r\n tjSheets: {\r\n ...this.pendingNewGame.tjSheets,\r\n },\r\n };\r\n break;\r\n case PendingGameType.UCSDSheets:\r\n this.pendingNewGame = {\r\n ...this.pendingNewGame,\r\n ucsdSheets: {\r\n ...this.pendingNewGame.ucsdSheets,\r\n },\r\n };\r\n break;\r\n default:\r\n assertNever(this.pendingNewGame);\r\n }\r\n }\r\n }\r\n\r\n public createPendingNewPlayer(teamName: string): void {\r\n this.pendingNewPlayer = new Player(\"\", teamName, /* isStarter */ false);\r\n this.dialogState.visibleDialog = ModalVisibilityStatus.AddPlayer;\r\n }\r\n\r\n public createPendingSheet(): void {\r\n this.pendingSheet = {\r\n roundNumber: this.sheetsState.roundNumber ?? 1,\r\n sheetId: this.sheetsState.sheetId ?? \"\",\r\n };\r\n this.dialogState.visibleDialog = ModalVisibilityStatus.ExportToSheets;\r\n }\r\n\r\n public removePlayerToFirstTeamInPendingNewGame(player: Player): void {\r\n if (this.pendingNewGame?.type === PendingGameType.Manual) {\r\n this.pendingNewGame.manual.firstTeamPlayers = this.pendingNewGame.manual.firstTeamPlayers.filter(\r\n (p) => p !== player\r\n );\r\n }\r\n }\r\n\r\n public removePlayerToSecondTeamInPendingNewGame(player: Player): void {\r\n if (this.pendingNewGame?.type === PendingGameType.Manual) {\r\n this.pendingNewGame.manual.secondTeamPlayers = this.pendingNewGame.manual.secondTeamPlayers.filter(\r\n (p) => p !== player\r\n );\r\n }\r\n }\r\n\r\n public setFontFamily(listedFont: string): void {\r\n // It's possible the listed font has default fonts listed too. Cut them out so that we don't keep compounding\r\n // the default fonts on top.\r\n const commaIndex: number = listedFont.indexOf(\",\");\r\n if (commaIndex >= 0) {\r\n listedFont = listedFont.substring(0, commaIndex);\r\n }\r\n\r\n this.fontFamily = listedFont + \", \" + DefaultFontFamily;\r\n }\r\n\r\n public setPendingNewGameType(type: PendingGameType): void {\r\n if (this.pendingNewGame != undefined) {\r\n this.pendingNewGame.type = type;\r\n if (\r\n this.pendingNewGame.type === PendingGameType.QBJRegistration &&\r\n this.pendingNewGame.registration == undefined\r\n ) {\r\n this.pendingNewGame.registration = {\r\n firstTeamPlayers: undefined,\r\n players: [],\r\n secondTeamPlayers: undefined,\r\n };\r\n } else if (\r\n this.pendingNewGame.type === PendingGameType.TJSheets &&\r\n this.pendingNewGame.tjSheets == undefined\r\n ) {\r\n this.pendingNewGame.tjSheets = {\r\n firstTeamPlayersFromRosters: undefined,\r\n playersFromRosters: undefined,\r\n rostersUrl: undefined,\r\n secondTeamPlayersFromRosters: undefined,\r\n };\r\n } else if (\r\n this.pendingNewGame.type === PendingGameType.UCSDSheets &&\r\n this.pendingNewGame.ucsdSheets == undefined\r\n ) {\r\n this.pendingNewGame.ucsdSheets = {\r\n firstTeamPlayersFromRosters: undefined,\r\n playersFromRosters: undefined,\r\n rostersUrl: undefined,\r\n secondTeamPlayersFromRosters: undefined,\r\n };\r\n }\r\n }\r\n }\r\n\r\n public setPendingNewGameCycles(cycles: Cycle[]): void {\r\n if (this.pendingNewGame == undefined) {\r\n return;\r\n }\r\n\r\n if (this.pendingNewGame.type === PendingGameType.Manual) {\r\n this.pendingNewGame.manual.cycles = cycles;\r\n } else if (this.pendingNewGame.type === PendingGameType.QBJRegistration) {\r\n this.pendingNewGame.registration.cycles = cycles;\r\n }\r\n }\r\n\r\n public setPendingNewGameFormat(gameFormat: IGameFormat): void {\r\n if (this.pendingNewGame == undefined) {\r\n return;\r\n }\r\n\r\n this.pendingNewGame.gameFormat = gameFormat;\r\n }\r\n\r\n public setPendingNewGameRegistrationErrorMessage(message: string): void {\r\n if (this.pendingNewGame?.type !== PendingGameType.QBJRegistration) {\r\n return;\r\n }\r\n\r\n this.pendingNewGame.registration.errorMessage = message;\r\n }\r\n\r\n public setPendingNewGameRosters(players: Player[]): void {\r\n if (this.pendingNewGame?.type == undefined) {\r\n return;\r\n }\r\n\r\n if (this.pendingNewGame.type === PendingGameType.QBJRegistration) {\r\n const registration: IPendingQBJRegistrationNewGameState = this.pendingNewGame.registration;\r\n registration.players = players;\r\n\r\n registration.firstTeamPlayers = [];\r\n registration.secondTeamPlayers = [];\r\n\r\n if (players.length < 2) {\r\n return;\r\n }\r\n\r\n const firstTeam: string = players[0].teamName;\r\n const secondTeam: string = players.find((player) => player.teamName !== firstTeam)?.teamName ?? firstTeam;\r\n if (firstTeam === secondTeam) {\r\n // Handle the unapproved case of one team only gracefully by having both teams refer to the same one\r\n registration.firstTeamPlayers = players;\r\n registration.secondTeamPlayers = players;\r\n return;\r\n }\r\n\r\n for (const player of players) {\r\n if (player.teamName === firstTeam) {\r\n registration.firstTeamPlayers.push(player);\r\n } else if (player.teamName === secondTeam) {\r\n registration.secondTeamPlayers.push(player);\r\n }\r\n }\r\n\r\n return;\r\n }\r\n\r\n if (this.pendingNewGame.type !== PendingGameType.Manual) {\r\n const sheetsState: IPendingFromSheetsNewGameState =\r\n this.pendingNewGame.type === PendingGameType.TJSheets\r\n ? this.pendingNewGame.tjSheets\r\n : this.pendingNewGame.ucsdSheets;\r\n sheetsState.playersFromRosters = players;\r\n sheetsState.firstTeamPlayersFromRosters = [];\r\n sheetsState.secondTeamPlayersFromRosters = [];\r\n }\r\n }\r\n\r\n public setPendingNewGameRostersUrl(url: string): void {\r\n if (this.pendingNewGame?.type == undefined) {\r\n return;\r\n }\r\n\r\n if (this.pendingNewGame.type === PendingGameType.TJSheets) {\r\n this.pendingNewGame.tjSheets.rostersUrl = url;\r\n } else if (this.pendingNewGame.type === PendingGameType.UCSDSheets) {\r\n this.pendingNewGame.ucsdSheets.rostersUrl = url;\r\n }\r\n }\r\n\r\n public setPendingNewGameFirstTeamPlayers(players: Player[]): void {\r\n if (this.pendingNewGame?.type == undefined) {\r\n return;\r\n }\r\n\r\n switch (this.pendingNewGame.type) {\r\n case PendingGameType.TJSheets:\r\n this.pendingNewGame.tjSheets.firstTeamPlayersFromRosters = players;\r\n break;\r\n case PendingGameType.UCSDSheets:\r\n this.pendingNewGame.ucsdSheets.firstTeamPlayersFromRosters = players;\r\n break;\r\n case PendingGameType.Manual:\r\n this.pendingNewGame.manual.firstTeamPlayers = players;\r\n break;\r\n case PendingGameType.QBJRegistration:\r\n this.pendingNewGame.registration.firstTeamPlayers = players;\r\n break;\r\n default:\r\n assertNever(this.pendingNewGame);\r\n }\r\n }\r\n\r\n public setPendingNewGameSecondTeamPlayers(players: Player[]): void {\r\n if (this.pendingNewGame?.type == undefined) {\r\n return;\r\n }\r\n\r\n switch (this.pendingNewGame.type) {\r\n case PendingGameType.TJSheets:\r\n this.pendingNewGame.tjSheets.secondTeamPlayersFromRosters = players;\r\n break;\r\n case PendingGameType.UCSDSheets:\r\n this.pendingNewGame.ucsdSheets.secondTeamPlayersFromRosters = players;\r\n break;\r\n case PendingGameType.Manual:\r\n this.pendingNewGame.manual.secondTeamPlayers = players;\r\n break;\r\n case PendingGameType.QBJRegistration:\r\n this.pendingNewGame.registration.secondTeamPlayers = players;\r\n break;\r\n default:\r\n assertNever(this.pendingNewGame);\r\n }\r\n }\r\n\r\n public nextCycle(): void {\r\n this.setCycleIndex(this.cycleIndex + 1);\r\n }\r\n\r\n public previousCycle(): void {\r\n if (this.cycleIndex > 0) {\r\n this.setCycleIndex(this.cycleIndex - 1);\r\n }\r\n }\r\n\r\n public setCycleIndex(newIndex: number): void {\r\n if (newIndex >= 0) {\r\n this.cycleIndex = newIndex;\r\n\r\n // Clear the selected words, since it's not relevant to the next question\r\n this.selectedWordIndex = -1;\r\n }\r\n }\r\n\r\n public setBuildVersion(version: string | undefined): void {\r\n this.buildVersion = version;\r\n }\r\n\r\n public setCustomExport(customExport: ICustomExport): void {\r\n this.customExportOptions = customExport;\r\n }\r\n\r\n public setCustomExportIntervalId(intervalId: number | undefined): void {\r\n this.customExportIntervalId = intervalId;\r\n }\r\n\r\n public setCustomExportStatus(status: string | undefined): void {\r\n this.customExportStatus = status;\r\n }\r\n\r\n public setExportRoundNumber(newRoundNumber: number): void {\r\n this.exportRoundNumber = newRoundNumber;\r\n }\r\n\r\n public setHideNewGame(value: boolean): void {\r\n this.hideNewGame = value;\r\n }\r\n\r\n public setImportGameStatus(status: IStatus): void {\r\n this.importGameStatus = status;\r\n }\r\n\r\n public setIsEditingCycleIndex(isEditingCycleIndex: boolean): void {\r\n this.isEditingCycleIndex = isEditingCycleIndex;\r\n }\r\n\r\n public setPacketFilename(name: string): void {\r\n this.packetFilename = name;\r\n }\r\n\r\n public setPacketStatus(packetStatus: IStatus): void {\r\n this.packetParseStatus = packetStatus;\r\n }\r\n\r\n public setPendingBonusProtest(teamName: string, questionIndex: number, part: number): void {\r\n this.pendingBonusProtestEvent = {\r\n partIndex: part,\r\n questionIndex,\r\n givenAnswer: \"\",\r\n reason: \"\",\r\n teamName,\r\n };\r\n this.dialogState.visibleDialog = ModalVisibilityStatus.BonusProtest;\r\n }\r\n\r\n public setPendingTossupProtest(teamName: string, questionIndex: number, position: number): void {\r\n this.pendingTossupProtestEvent = {\r\n position,\r\n questionIndex,\r\n givenAnswer: \"\",\r\n reason: \"\",\r\n teamName,\r\n };\r\n this.dialogState.visibleDialog = ModalVisibilityStatus.TossupProtest;\r\n }\r\n\r\n public setPronunciationGuideColor(color: string | undefined): void {\r\n this.pronunciationGuideColor = color;\r\n }\r\n\r\n public setQuestionFontColor(color: string | undefined): void {\r\n this.questionFontColor = color;\r\n }\r\n\r\n public setQuestionFontSize(size: number): void {\r\n this.questionFontSize = size;\r\n }\r\n\r\n public setSelectedWordIndex(newIndex: number): void {\r\n this.selectedWordIndex = newIndex;\r\n }\r\n\r\n public setYappServiceUrl(url: string | undefined): void {\r\n this.yappServiceUrl = url;\r\n }\r\n\r\n public toggleBonusHighlight(): void {\r\n this.noBonusHighlight = !this.noBonusHighlight;\r\n }\r\n\r\n public toggleClockVisibility(): void {\r\n this.isClockHidden = !this.isClockHidden;\r\n }\r\n\r\n public toggleCustomExportStatusVisibility(): void {\r\n this.isCustomExportStatusHidden = !this.isCustomExportStatusHidden;\r\n }\r\n\r\n public toggleDarkMode(): void {\r\n this.useDarkMode = !this.useDarkMode;\r\n }\r\n\r\n public toggleEventLogVisibility(): void {\r\n this.isEventLogHidden = !this.isEventLogHidden;\r\n }\r\n\r\n public toggleHideBonusOnDeadTossup(): void {\r\n this.hideBonusOnDeadTossup = !this.hideBonusOnDeadTossup;\r\n }\r\n\r\n public toggleScoreVerticality(): void {\r\n this.isScoreVertical = !this.isScoreVertical;\r\n }\r\n\r\n public hideBuzzMenu(): void {\r\n this.buzzMenuState.visible = false;\r\n }\r\n\r\n public resetCustomExport(): void {\r\n this.customExportOptions = undefined;\r\n }\r\n\r\n public resetFontFamily(): void {\r\n this.fontFamily = DefaultFontFamily;\r\n }\r\n\r\n public resetPacketFilename(): void {\r\n this.packetFilename = undefined;\r\n }\r\n\r\n public resetPendingBonusProtest(): void {\r\n this.pendingBonusProtestEvent = undefined;\r\n this.dialogState.visibleDialog = ModalVisibilityStatus.None;\r\n }\r\n\r\n public resetPendingNewGame(): void {\r\n this.packetParseStatus = undefined;\r\n this.importGameStatus = undefined;\r\n if (this.pendingNewGame != undefined) {\r\n // Clear everything but the game format and info derived from the roster URL\r\n this.pendingNewGame.packet = new PacketState();\r\n\r\n switch (this.pendingNewGame.type) {\r\n case PendingGameType.Manual:\r\n this.pendingNewGame.manual.cycles = undefined;\r\n break;\r\n case PendingGameType.QBJRegistration:\r\n this.pendingNewGame.registration.cycles = undefined;\r\n this.clearPendingNewGameRegistrationStatus();\r\n break;\r\n case undefined:\r\n case PendingGameType.TJSheets:\r\n case PendingGameType.UCSDSheets:\r\n // Don't clear the sheets URL or the players\r\n break;\r\n default:\r\n assertNever(this.pendingNewGame);\r\n }\r\n }\r\n }\r\n\r\n public resetPendingNewPlayer(): void {\r\n this.pendingNewPlayer = undefined;\r\n this.dialogState.visibleDialog = ModalVisibilityStatus.None;\r\n }\r\n\r\n public resetPendingSheet(): void {\r\n this.pendingSheet = undefined;\r\n this.dialogState.visibleDialog = ModalVisibilityStatus.None;\r\n }\r\n\r\n public resetPendingTossupProtest(): void {\r\n this.pendingTossupProtestEvent = undefined;\r\n this.dialogState.visibleDialog = ModalVisibilityStatus.None;\r\n }\r\n\r\n public resetSheetsId(): void {\r\n this.sheetsState.sheetId = undefined;\r\n this.sheetsState.sheetType = undefined;\r\n }\r\n\r\n public showBuzzMenu(clearSelectedWordOnClose: boolean): void {\r\n this.buzzMenuState.visible = true;\r\n this.buzzMenuState.clearSelectedWordOnClose = clearSelectedWordOnClose;\r\n }\r\n\r\n // We have to do this call here because this is where the information is available\r\n public showFontDialog(): void {\r\n this.dialogState.showFontDialog(\r\n this.fontFamily,\r\n this.questionFontSize,\r\n this.questionFontColor,\r\n this.pronunciationGuideColor\r\n );\r\n }\r\n\r\n public updatePendingProtestGivenAnswer(givenAnswer: string): void {\r\n if (this.pendingBonusProtestEvent != undefined) {\r\n this.pendingBonusProtestEvent.givenAnswer = givenAnswer;\r\n } else if (this.pendingTossupProtestEvent != undefined) {\r\n this.pendingTossupProtestEvent.givenAnswer = givenAnswer;\r\n }\r\n }\r\n\r\n public updatePendingProtestReason(reason: string): void {\r\n if (this.pendingBonusProtestEvent != undefined) {\r\n this.pendingBonusProtestEvent.reason = reason;\r\n } else if (this.pendingTossupProtestEvent != undefined) {\r\n this.pendingTossupProtestEvent.reason = reason;\r\n }\r\n }\r\n\r\n public updatePendingBonusProtestPart(part: string | number): void {\r\n if (this.pendingBonusProtestEvent != undefined) {\r\n const partIndex = typeof part === \"string\" ? parseInt(part, 10) : part;\r\n this.pendingBonusProtestEvent.partIndex = partIndex;\r\n }\r\n }\r\n\r\n public updatePendingNewPlayerName(name: string): void {\r\n if (this.pendingNewPlayer == undefined) {\r\n return;\r\n }\r\n\r\n this.pendingNewPlayer.name = name;\r\n }\r\n\r\n public updatePendingNewPlayerTeamName(teamName: string): void {\r\n if (this.pendingNewPlayer == undefined) {\r\n return;\r\n }\r\n\r\n this.pendingNewPlayer.teamName = teamName;\r\n }\r\n\r\n public updatePendingSheetRoundNumber(roundNumber: number): void {\r\n if (this.pendingSheet == undefined) {\r\n return;\r\n }\r\n\r\n this.pendingSheet.roundNumber = roundNumber;\r\n }\r\n\r\n public updatePendingSheetId(sheetId: string): void {\r\n if (this.pendingSheet == undefined) {\r\n return;\r\n }\r\n\r\n this.pendingSheet.sheetId = sheetId;\r\n }\r\n}\r\n","export const enum StatusDisplayType {\r\n MessageDialog = 0,\r\n Label = 1,\r\n}\r\n","import { assertNever } from \"@fluentui/utilities\";\r\nimport { makeAutoObservable } from \"mobx\";\r\nimport { IStatus } from \"../IStatus\";\r\nimport { ICustomExport } from \"./CustomExport\";\r\n\r\nimport * as CustomExport from \"./CustomExport\";\r\nimport * as QBJ from \"../qbj/QBJ\";\r\nimport { GameState } from \"./GameState\";\r\nimport { UIState } from \"./UIState\";\r\nimport { StatusDisplayType } from \"./StatusDisplayType\";\r\n\r\nconst minimumIntervalInMs = 5000;\r\n\r\nexport class AppState {\r\n public static instance: AppState = new AppState();\r\n\r\n public game: GameState;\r\n\r\n public uiState: UIState;\r\n\r\n constructor() {\r\n makeAutoObservable(this);\r\n\r\n this.game = new GameState();\r\n this.uiState = new UIState();\r\n }\r\n\r\n // Only use in tests\r\n public static resetInstance(): void {\r\n this.instance = new AppState();\r\n }\r\n\r\n // Could do a version with callbacks. There are 4 places this gets called from, and 3 use the same callback\r\n // Could also just do a bool, and pass in a different value (e.g. enum) if we want more flexibility in the future\r\n public handleCustomExport(displayType: StatusDisplayType, source: CustomExport.ExportSource): Promise {\r\n // Custom export must be defined and we must have an existing game\r\n if (\r\n this.uiState == undefined ||\r\n this.uiState.customExportOptions == undefined ||\r\n this.game.cycles.length === 0\r\n ) {\r\n return Promise.resolve();\r\n }\r\n\r\n const customExport: ICustomExport = this.uiState.customExportOptions;\r\n let exportPromise: Promise | undefined;\r\n switch (customExport.type) {\r\n case \"Raw\":\r\n exportPromise = customExport.onExport(CustomExport.convertGameToExportFields(this.game), { source });\r\n break;\r\n case \"QBJ\":\r\n exportPromise = customExport.onExport(QBJ.toQBJ(this.game, this.uiState.packetFilename), { source });\r\n break;\r\n default:\r\n assertNever(customExport);\r\n }\r\n\r\n return exportPromise\r\n .then((status) => {\r\n if (status.isError) {\r\n switch (displayType) {\r\n case StatusDisplayType.MessageDialog:\r\n this.uiState.dialogState.showOKMessageDialog(\r\n \"Export Error\",\r\n `Export failed: ${status.status}.`\r\n );\r\n break;\r\n case StatusDisplayType.Label:\r\n this.uiState.setCustomExportStatus(`Export failed: ${status.status}.`);\r\n break;\r\n default:\r\n assertNever(displayType);\r\n }\r\n } else {\r\n this.game.markUpdateComplete();\r\n switch (displayType) {\r\n case StatusDisplayType.MessageDialog:\r\n this.uiState.dialogState.showOKMessageDialog(\"Export Succeeded\", \"Export succeeded.\");\r\n break;\r\n case StatusDisplayType.Label:\r\n this.uiState.setCustomExportStatus(\"Export successful.\");\r\n break;\r\n default:\r\n assertNever(displayType);\r\n }\r\n }\r\n })\r\n .catch((e) => {\r\n const message = e.message ? e.message : JSON.stringify(e);\r\n switch (displayType) {\r\n case StatusDisplayType.MessageDialog:\r\n this.uiState.dialogState.showOKMessageDialog(\r\n \"Export Error\",\r\n `Error in exporting the game. Hit an exception. Exception message: ${message}`\r\n );\r\n break;\r\n case StatusDisplayType.Label:\r\n this.uiState.setCustomExportStatus(\r\n `Error in exporting the game. Hit an exception. Exception message: ${message}`\r\n );\r\n break;\r\n default:\r\n assertNever(displayType);\r\n }\r\n });\r\n }\r\n\r\n // Need to think if we want this state in UIState or here. Odd to have the interval live elsewhere\r\n public setCustomExportInterval(interval: number | undefined): void {\r\n clearInterval(this.uiState.customExportIntervalId);\r\n\r\n if (interval == undefined) {\r\n return;\r\n }\r\n\r\n if (interval < minimumIntervalInMs) {\r\n interval = minimumIntervalInMs;\r\n }\r\n\r\n const newIntervalId = setInterval(() => {\r\n // Only export if the game has changes. We do this check here instead of handleCustomExport because the\r\n // user should be able to explicitly export multiple times (to create new files, for example), but that doesn't\r\n // make sense to do for something running from the timer.\r\n if (this.game.hasUpdates) {\r\n this.handleCustomExport(StatusDisplayType.Label, \"Timer\");\r\n }\r\n }, interval);\r\n this.uiState.setCustomExportIntervalId(newIntervalId);\r\n }\r\n}\r\n","import * as he from \"he\";\r\n\r\nimport { AppState } from \"../state/AppState\";\r\nimport { IPacket } from \"../state/IPacket\";\r\nimport { Bonus, BonusPart, PacketState, Tossup } from \"../state/PacketState\";\r\nimport { UIState } from \"../state/UIState\";\r\n\r\nexport function loadPacket(parsedPacket: IPacket): PacketState | undefined {\r\n const appState: AppState = AppState.instance;\r\n const uiState: UIState = appState.uiState;\r\n\r\n if (parsedPacket.tossups == undefined) {\r\n uiState.setPacketStatus({\r\n isError: true,\r\n status: \"Error loading packet: Packet doesn't have a tossups field.\",\r\n });\r\n return;\r\n }\r\n\r\n const tossups: Tossup[] = parsedPacket.tossups.map(\r\n (tossup) =>\r\n new Tossup(\r\n he.decode(tossup.question),\r\n he.decode(tossup.answer),\r\n tossup.metadata ? he.decode(tossup.metadata) : tossup.metadata\r\n )\r\n );\r\n let bonuses: Bonus[] = [];\r\n\r\n if (parsedPacket.bonuses) {\r\n bonuses = parsedPacket.bonuses.map((bonus, index) => {\r\n if (bonus.answers.length !== bonus.parts.length || bonus.answers.length !== bonus.values.length) {\r\n const errorMessage = `Error loading packet: Unequal number of parts, answers, and values for bonus ${index}. Answers #: ${bonus.answers.length}, Parts #: ${bonus.parts.length}, Values #: ${bonus.values.length}`;\r\n uiState.setPacketStatus({\r\n isError: true,\r\n status: errorMessage,\r\n });\r\n throw errorMessage;\r\n }\r\n\r\n const parts: BonusPart[] = [];\r\n for (let i = 0; i < bonus.answers.length; i++) {\r\n parts.push({\r\n answer: he.decode(bonus.answers[i]),\r\n question: he.decode(bonus.parts[i]),\r\n value: bonus.values[i],\r\n difficultyModifier: bonus.difficultyModifiers ? bonus.difficultyModifiers[i] : undefined,\r\n });\r\n }\r\n\r\n return new Bonus(\r\n he.decode(bonus.leadin),\r\n parts,\r\n bonus.metadata ? he.decode(bonus.metadata) : bonus.metadata\r\n );\r\n });\r\n }\r\n\r\n const packet = new PacketState();\r\n packet.setTossups(tossups);\r\n packet.setBonuses(bonuses);\r\n\r\n const packetName: string = uiState.packetFilename != undefined ? `\"${uiState.packetFilename}\"` : \"\";\r\n uiState.setPacketStatus({\r\n isError: false,\r\n status: `Packet ${packetName} loaded. ${tossups.length} tossup(s), ${bonuses.length} bonus(es).`,\r\n });\r\n\r\n return packet;\r\n}\r\n","import * as jsxRuntime from \"C:\\\\Users\\\\Alejandro\\\\Source\\\\Repos\\\\QuizBowlReader_TS_ReactMobx\\\\node_modules\\\\react\\\\jsx-runtime.js\"\nexport const jsx = jsxRuntime.jsx\nexport const jsxs = jsxRuntime.jsxs\nexport const Fragment = jsxRuntime.Fragment","import { observer } from \"mobx-react-lite\";\r\nimport React, { ReactElement } from \"react\";\r\nimport { AppState } from \"../state/AppState\";\r\n\r\nexport const StateContext = React.createContext(AppState.instance);\r\n\r\nexport const StateProvider = observer(function StateProvider(\r\n props: React.PropsWithChildren\r\n): ReactElement {\r\n return {props.children};\r\n});\r\n\r\nexport interface IStateProviderProps {\r\n appState: AppState;\r\n}\r\n","import React from \"react\";\r\nimport { observer } from \"mobx-react-lite\";\r\nimport { DefaultButton, IButtonStyles, PrimaryButton } from \"@fluentui/react/lib/Button\";\r\nimport { TextField, ITextFieldStyles } from \"@fluentui/react/lib/TextField\";\r\nimport { useId } from \"@fluentui/react-hooks\";\r\n\r\nimport { UIState } from \"../state/UIState\";\r\nimport { AppState } from \"../state/AppState\";\r\nimport { ILabelStyles, Label, TooltipHost } from \"@fluentui/react\";\r\nimport { StateContext } from \"../contexts/StateContext\";\r\nimport { StatusDisplayType } from \"../state/StatusDisplayType\";\r\n\r\nconst ReturnKeyCode = 13;\r\nconst questionNumberTextStyle: Partial = {\r\n root: {\r\n display: \"inline-flex\",\r\n width: 40,\r\n margin: \"0 10px\",\r\n },\r\n field: {\r\n textAlign: \"center\",\r\n },\r\n};\r\nconst previousButtonStyle: Partial = {\r\n root: {\r\n marginRight: 10,\r\n },\r\n};\r\nconst questionLableStyle: Partial = {\r\n root: {\r\n display: \"inline-flex\",\r\n },\r\n};\r\nconst nextButtonStyle: Partial = {\r\n root: {\r\n marginLeft: 10,\r\n },\r\n};\r\n\r\nexport const CycleChooser = observer(function CycleChooser() {\r\n const appState: AppState = React.useContext(StateContext);\r\n const onPreviousClickHandler = React.useCallback(() => onPreviousClick(appState), [appState]);\r\n const onNextClickHandler = React.useCallback(() => onNextClick(appState), [appState]);\r\n const onProposedQuestionNumberBlurHandler = React.useCallback((ev) => onProposedQuestionNumberBlur(ev, appState), [\r\n appState,\r\n ]);\r\n const onProposedQuestionNumberKeyDownHandler = React.useCallback(\r\n (ev) => onProposedQuestionNumberKeyDown(ev, appState),\r\n [appState]\r\n );\r\n const onQuestionLabelDoubleClickHandler = React.useCallback(() => onQuestionLabelDoubleClick(appState), [appState]);\r\n\r\n const uiState: UIState = appState.uiState;\r\n\r\n const previousButtonTooltipId: string = useId();\r\n const previousButton: JSX.Element = (\r\n \r\n \r\n ← Previous\r\n \r\n \r\n );\r\n\r\n let nextButton: JSX.Element;\r\n let nextButtonTooltip;\r\n const doesNextButtonExport: boolean = shouldNextButtonExport(appState);\r\n if (doesNextButtonExport) {\r\n nextButtonTooltip = \"Export\";\r\n nextButton = (\r\n \r\n Export...\r\n \r\n );\r\n } else {\r\n nextButtonTooltip = \"Next (N)\";\r\n nextButton = (\r\n \r\n Next →\r\n \r\n );\r\n }\r\n\r\n const nextButtonTooltipId: string = useId();\r\n const nextButtonWrapper: JSX.Element = (\r\n \r\n {nextButton}\r\n \r\n );\r\n\r\n const questionNumber: number = uiState.cycleIndex + 1;\r\n let questionNumberViewer: JSX.Element | null = null;\r\n if (uiState.isEditingCycleIndex) {\r\n questionNumberViewer = (\r\n \r\n );\r\n } else {\r\n questionNumberViewer = (\r\n \r\n );\r\n }\r\n\r\n return (\r\n
\r\n {previousButton}\r\n {questionNumberViewer}\r\n {nextButtonWrapper}\r\n
\r\n );\r\n});\r\n\r\nfunction shouldNextButtonExport(appState: AppState): boolean {\r\n const nextCycleIndex: number = appState.uiState.cycleIndex + 1;\r\n return nextCycleIndex >= appState.game.playableCycles.length;\r\n}\r\n\r\nfunction onProposedQuestionNumberBlur(event: React.FocusEvent, appState: AppState): void {\r\n commitCycleIndex(appState, event.currentTarget.value);\r\n}\r\n\r\nfunction onProposedQuestionNumberKeyDown(event: React.KeyboardEvent, appState: AppState): void {\r\n if (event.which == ReturnKeyCode) {\r\n commitCycleIndex(appState, event.currentTarget.value);\r\n }\r\n}\r\n\r\nfunction onNextClick(appState: AppState): void {\r\n if (shouldNextButtonExport(appState)) {\r\n // If they use Sheets, show the Export Sheets dialog. Otherwise, show the Export JSON dialog\r\n if (appState.uiState.customExportOptions != undefined) {\r\n appState.handleCustomExport(StatusDisplayType.MessageDialog, \"NextButton\");\r\n } else if (appState.uiState.sheetsState.sheetId != undefined) {\r\n appState.uiState.createPendingSheet();\r\n } else {\r\n appState.uiState.dialogState.showExportToJsonDialog();\r\n }\r\n } else {\r\n appState.uiState.nextCycle();\r\n }\r\n}\r\n\r\nfunction onPreviousClick(appState: AppState): void {\r\n appState.uiState.previousCycle();\r\n}\r\n\r\nfunction onQuestionLabelDoubleClick(appState: AppState): void {\r\n // The question number is one higher than the cycle index\r\n appState.uiState.setIsEditingCycleIndex(true);\r\n}\r\n\r\nfunction commitCycleIndex(appState: AppState, value: string): void {\r\n if (value == undefined) {\r\n return;\r\n }\r\n\r\n const propsedCycleIndex: number = parseInt(value, 10);\r\n if (propsedCycleIndex >= 1 && propsedCycleIndex <= appState.game.packet.tossups.length) {\r\n appState.uiState.setCycleIndex(propsedCycleIndex - 1);\r\n }\r\n\r\n appState.uiState.setIsEditingCycleIndex(false);\r\n}\r\n","import React from \"react\";\r\nimport { AppState } from \"../state/AppState\";\r\nimport { Cycle } from \"../state/Cycle\";\r\nimport { UIState } from \"../state/UIState\";\r\n\r\nexport function selectWordFromClick(event: React.MouseEvent): void {\r\n const appState: AppState = AppState.instance;\r\n const target = event.target as HTMLDivElement;\r\n\r\n // I'd like to avoid looking for a specific HTML element instead of a class. This would mean giving QuestionWord a\r\n // fixed class.\r\n const questionWord: HTMLSpanElement | null = target.closest(\"span\");\r\n if (questionWord == undefined || questionWord.getAttribute == undefined) {\r\n return;\r\n }\r\n\r\n const index = parseInt(questionWord.getAttribute(\"data-index\") ?? \"\", 10);\r\n if (index < 0 || isNaN(index)) {\r\n return;\r\n }\r\n\r\n const uiState: UIState = appState.uiState;\r\n const selectedIndex = uiState.selectedWordIndex === index ? -1 : index;\r\n uiState.setSelectedWordIndex(selectedIndex);\r\n uiState.showBuzzMenu(/* clearSelectedWordOnClose */ true);\r\n\r\n event.preventDefault();\r\n event.stopPropagation();\r\n}\r\n\r\nexport function selectWordFromKeyboardEvent(event: React.KeyboardEvent): void {\r\n const appState: AppState = AppState.instance;\r\n const target = event.target as HTMLDivElement;\r\n\r\n // We're looking for spans with the word index that matches the selectedWordIndex\r\n const selectedWordIndexString: string = appState.uiState.selectedWordIndex.toString();\r\n const questionWords: HTMLCollectionOf = target.getElementsByTagName(\"span\");\r\n for (let i = 0; i < questionWords.length; i++) {\r\n const questionWord: HTMLSpanElement = questionWords[i];\r\n\r\n if (questionWord.getAttribute(\"data-index\") === selectedWordIndexString) {\r\n appState.uiState.showBuzzMenu(/* clearSelectedWordOnClose */ false);\r\n break;\r\n }\r\n }\r\n\r\n event.preventDefault();\r\n event.stopPropagation();\r\n}\r\n\r\nexport function throwOutTossup(cycle: Cycle, tossupNumber: number): void {\r\n const appState: AppState = AppState.instance;\r\n\r\n appState.uiState.dialogState.showOKCancelMessageDialog(\r\n \"Throw out Tossup\",\r\n \"Click OK to throw out the tossup. To undo this, click on the X next to its event in the Event Log.\",\r\n () => onConfirmThrowOutTossup(cycle, tossupNumber)\r\n );\r\n}\r\n\r\nfunction onConfirmThrowOutTossup(cycle: Cycle, tossupNumber: number) {\r\n const appState: AppState = AppState.instance;\r\n cycle.addThrownOutTossup(tossupNumber - 1);\r\n appState.uiState.setSelectedWordIndex(-1);\r\n}\r\n","import * as React from \"react\";\r\nimport { observer } from \"mobx-react-lite\";\r\nimport { mergeStyleSets, memoizeFunction } from \"@fluentui/react\";\r\n\r\nimport { IFormattedText } from \"../parser/IFormattedText\";\r\nimport { StateContext } from \"../contexts/StateContext\";\r\nimport { AppState } from \"../state/AppState\";\r\n\r\nexport const FormattedText = observer(function FormattedText(props: IFormattedTextProps): JSX.Element {\r\n const appState: AppState = React.useContext(StateContext);\r\n const classes: IFormattedTextClassNames = useStyles(appState.uiState.pronunciationGuideColor, props.disabled);\r\n\r\n const elements: JSX.Element[] = [];\r\n for (let i = 0; i < props.segments.length; i++) {\r\n elements.push();\r\n }\r\n\r\n const className: string = props.className ? `${classes.text} ${props.className}` : classes.text;\r\n return
{elements}
;\r\n});\r\n\r\nconst FormattedSegment = observer(function FormattedSegment(props: IFormattedSegmentProps) {\r\n // I used inline styles with divs for each individual element, but that messes up kerning when punctuation\r\n // following the text has a different format. Basic formatting tags (, , ) will keep them together.\r\n let element: JSX.Element = <>{props.segment.text};\r\n if (props.segment.bolded) {\r\n element = {element};\r\n }\r\n\r\n if (props.segment.emphasized) {\r\n element = {element};\r\n }\r\n\r\n if (props.segment.underlined) {\r\n element = {element};\r\n }\r\n\r\n if (props.segment.subscripted) {\r\n element = {element};\r\n }\r\n\r\n if (props.segment.superscripted) {\r\n element = {element};\r\n }\r\n\r\n // Obsolete, but here for back-compat with YAPP versions before 0.2.4\r\n if (props.segment.required) {\r\n element = (\r\n \r\n {element}\r\n \r\n );\r\n }\r\n\r\n if (props.segment.pronunciation) {\r\n element = {element};\r\n }\r\n\r\n return element;\r\n});\r\n\r\nexport interface IFormattedTextProps {\r\n segments: IFormattedText[];\r\n className?: string;\r\n disabled?: boolean;\r\n}\r\n\r\ninterface IFormattedSegmentProps {\r\n segment: IFormattedText;\r\n classNames: IFormattedTextClassNames;\r\n}\r\n\r\ninterface IFormattedTextClassNames {\r\n text: string;\r\n pronunciationGuide: string;\r\n}\r\n\r\nconst useStyles = memoizeFunction(\r\n (pronunciationGuideColor: string | undefined, disabled: boolean | undefined): IFormattedTextClassNames =>\r\n mergeStyleSets({\r\n text: {\r\n display: \"inline\",\r\n textDecorationSkipInk: \"none\",\r\n },\r\n pronunciationGuide: {\r\n // Don't override the color if it's disabled; the container has that responsibility\r\n color: disabled ? undefined : pronunciationGuideColor ?? \"#777777\",\r\n },\r\n })\r\n);\r\n","import * as React from \"react\";\r\nimport { observer } from \"mobx-react-lite\";\r\nimport { mergeStyleSets, memoizeFunction, ThemeContext, Theme } from \"@fluentui/react\";\r\n\r\nimport { IFormattedText } from \"../parser/IFormattedText\";\r\nimport { FormattedText } from \"./FormattedText\";\r\n\r\nexport const QuestionWord = observer(function QuestionWord(props: IQuestionWordProps): JSX.Element {\r\n return (\r\n \r\n {(theme) => {\r\n const classes = getClassNames(\r\n theme,\r\n props.selected,\r\n props.correct,\r\n props.wrong,\r\n props.index != undefined\r\n );\r\n return (\r\n \r\n \r\n \r\n );\r\n }}\r\n \r\n );\r\n});\r\n\r\ninterface IQuestionWordProps {\r\n word: IFormattedText[];\r\n index: number | undefined;\r\n selected?: boolean;\r\n correct?: boolean;\r\n wrong?: boolean;\r\n hovered?: boolean;\r\n componentRef?: React.MutableRefObject;\r\n}\r\n\r\ninterface IQuestionWordClassNames {\r\n word: string;\r\n}\r\n\r\n// This would be a great place for theming or settings\r\nconst getClassNames = memoizeFunction(\r\n (\r\n theme: Theme | undefined,\r\n selected: boolean | undefined,\r\n correct: boolean | undefined,\r\n wrong: boolean | undefined,\r\n isIndexDefined: boolean\r\n ): IQuestionWordClassNames =>\r\n mergeStyleSets({\r\n word: [\r\n { display: \"inline-flex\" },\r\n selected && {\r\n fontWeight: \"bold\",\r\n background: theme ? theme.palette.themeLight + \"20\" : \"rbg(192, 192, 192)\",\r\n },\r\n correct && {\r\n background: theme ? theme.palette.tealLight + \"20\" : \"rbg(0, 128, 128)\",\r\n textDecoration: \"underline solid\",\r\n },\r\n wrong && {\r\n background: theme ? theme.palette.red + \"20\" : \"rgb(128, 0, 0)\",\r\n textDecoration: \"underline wavy\",\r\n },\r\n correct &&\r\n wrong && {\r\n background: theme ? theme.palette.neutralLight : \"rgb(128, 128, 128)\",\r\n textDecoration: \"underline double\",\r\n },\r\n // Only highlight a word on hover if it's not in an existing state from selected/correct/wrong\r\n isIndexDefined &&\r\n !(selected || correct || wrong) && {\r\n \"&:hover\": {\r\n background: theme ? theme.palette.themeLighter : \"rgb(200, 200, 0)\",\r\n },\r\n },\r\n ],\r\n })\r\n);\r\n","// Contextual Menu with this state\r\n// Always show menu with Team Names as section headers, and players underneath\r\n// When we have more information in the cycles and the starting lineup, we can filter the menu to whoever is current\r\n// -If there are no wrong buzzes on this word:\r\n// ---> always show Correct | Wrong for the menu items\r\n// - If there is one wrong buzz on this word:\r\n// ---> show Correct | Wrong on that buzzer, and Correct | Wrong (first buzz) | Wrong (second buzz) for others\r\n// https://developer.microsoft.com/en-us/fluentui#/controls/web/contextualmenu\r\n\r\nimport * as React from \"react\";\r\nimport { observer } from \"mobx-react-lite\";\r\nimport { ContextualMenu, ContextualMenuItemType, IContextualMenuItem } from \"@fluentui/react/lib/ContextualMenu\";\r\n\r\nimport * as PlayerUtils from \"../state/PlayerUtils\";\r\nimport { Player } from \"../state/TeamState\";\r\nimport { Cycle } from \"../state/Cycle\";\r\nimport { Tossup } from \"../state/PacketState\";\r\nimport { IBuzzMarker } from \"../state/IBuzzMarker\";\r\nimport { AppState } from \"../state/AppState\";\r\nimport { ITossupAnswerEvent } from \"../state/Events\";\r\nimport { Theme, ThemeContext } from \"@fluentui/react\";\r\n\r\nexport const BuzzMenu = observer(function BuzzMenu(props: IBuzzMenuProps) {\r\n const onHideBuzzMenu: () => void = React.useCallback(() => onBuzzMenuDismissed(props), [props]);\r\n\r\n const teamNames: string[] = props.appState.game.teamNames;\r\n const menuItems: IContextualMenuItem[] = [];\r\n\r\n return (\r\n \r\n {(theme) => {\r\n for (const teamName of teamNames) {\r\n const subMenuItems: IContextualMenuItem[] = getPlayerMenuItems(props, theme, teamName);\r\n menuItems.push({\r\n key: `${teamName}_${menuItems.length}_Section`,\r\n itemType: ContextualMenuItemType.Section,\r\n sectionProps: {\r\n bottomDivider: true,\r\n title: teamName,\r\n items: subMenuItems,\r\n },\r\n });\r\n }\r\n\r\n return (\r\n \r\n );\r\n});\r\n\r\nfunction getPlayerMenuItems(props: IBuzzMenuProps, theme: Theme | undefined, teamName: string): IContextualMenuItem[] {\r\n // TODO: Need to support Wrong (1st buzz) and Wrong (2nd buzz)\r\n // TODO: Add some highlighting/indicator on the player to show that they have a buzz in a different word\r\n\r\n const players: Set = props.appState.game.getActivePlayers(teamName, props.appState.uiState.cycleIndex);\r\n const menuItems: IContextualMenuItem[] = [];\r\n\r\n let index = 0;\r\n for (const player of players.values()) {\r\n const topLevelKey = `Team_${teamName}_Player_${index}`;\r\n const isCorrectChecked: boolean =\r\n props.cycle.correctBuzz != undefined &&\r\n PlayerUtils.playersEqual(props.cycle.correctBuzz.marker.player, player) &&\r\n props.cycle.correctBuzz.marker.position === props.wordIndex;\r\n const isWrongChecked: boolean =\r\n props.cycle.wrongBuzzes != undefined &&\r\n props.cycle.wrongBuzzes.findIndex(\r\n (buzz) =>\r\n PlayerUtils.playersEqual(buzz.marker.player, player) && buzz.marker.position === props.wordIndex\r\n ) >= 0;\r\n const isProtestChecked: boolean =\r\n props.cycle.tossupProtests?.findIndex((protest) => protest.position === props.wordIndex) != undefined;\r\n\r\n const buzzMenuItemData: IBuzzMenuItemData = { props, player };\r\n\r\n const subMenuItems: IContextualMenuItem[] = [\r\n {\r\n key: `${topLevelKey}_correct`,\r\n text: \"Correct ✓\",\r\n data: buzzMenuItemData,\r\n canCheck: true,\r\n checked: isCorrectChecked,\r\n onClick: onCorrectClicked,\r\n },\r\n {\r\n key: `${topLevelKey}_wrong`,\r\n text: \"Wrong ✗\",\r\n data: buzzMenuItemData,\r\n canCheck: true,\r\n checked: isWrongChecked,\r\n onClick: onWrongClicked,\r\n },\r\n {\r\n key: `${topLevelKey}_Divider`,\r\n itemType: ContextualMenuItemType.Divider,\r\n },\r\n {\r\n key: `${topLevelKey}_protest`,\r\n text: \"Protest\",\r\n data: buzzMenuItemData,\r\n disabled: !(isWrongChecked || isCorrectChecked),\r\n canCheck: true,\r\n checked: isProtestChecked,\r\n onClick: onProtestClicked,\r\n },\r\n ];\r\n\r\n // TODO: See if we can improve the style, since the background doesn't change on hover. We can look into\r\n // tagging this with a class name and using react-jss, or using a style on the parent component (much harder to\r\n // do without folding this back into the render method)\r\n menuItems.push({\r\n key: `Team_${teamName}_Player_${index}`,\r\n text: player.name,\r\n style: {\r\n // + \"20\" makes the background translucent by 32/255 ~15%\r\n background: isCorrectChecked\r\n ? theme\r\n ? theme.palette.teal + \"20\"\r\n : \"rgb(0, 128, 128)\"\r\n : isWrongChecked\r\n ? theme\r\n ? theme.palette.red + \"20\"\r\n : \"rgb(128, 0, 0)\"\r\n : undefined,\r\n },\r\n subMenuProps: {\r\n items: subMenuItems,\r\n },\r\n });\r\n\r\n index++;\r\n }\r\n\r\n return menuItems;\r\n}\r\n\r\nfunction onBuzzMenuDismissed(props: IBuzzMenuProps): void {\r\n props.appState.uiState.hideBuzzMenu();\r\n\r\n if (props.appState.uiState.buzzMenuState.clearSelectedWordOnClose) {\r\n props.appState.uiState.setSelectedWordIndex(-1);\r\n }\r\n}\r\n\r\nfunction onCorrectClicked(\r\n ev?: React.MouseEvent | React.KeyboardEvent,\r\n item?: IContextualMenuItem\r\n) {\r\n if (item?.data == undefined || !isBuzzMenuItemData(item.data)) {\r\n // We need access to the player and props\r\n return;\r\n }\r\n\r\n const { props, player } = { ...item.data };\r\n\r\n if (item.checked) {\r\n props.cycle.removeCorrectBuzz();\r\n } else if (item?.checked === false) {\r\n // Don't include a bonus index if there should be no bonus for this correct buzz\r\n // TODO: This is an example of logic that should be moved out of the view layer\r\n const bonusIndex: number | undefined =\r\n props.appState.game.gameFormat.overtimeIncludesBonuses ||\r\n props.appState.uiState.cycleIndex < props.appState.game.gameFormat.regulationTossupCount\r\n ? props.bonusIndex\r\n : undefined;\r\n\r\n // If we don't know the number of parts, assume it's 3, which is standard\r\n const partsCount: number | undefined =\r\n bonusIndex != undefined && props.appState.game.packet.bonuses[bonusIndex] != undefined\r\n ? props.appState.game.packet.bonuses[bonusIndex].parts.length\r\n : 3;\r\n\r\n props.cycle.addCorrectBuzz(\r\n {\r\n player,\r\n position: item.data.props.wordIndex,\r\n points: props.tossup.getPointsAtPosition(props.appState.game.gameFormat, item.data.props.wordIndex),\r\n },\r\n props.tossupNumber - 1,\r\n props.appState.game.gameFormat,\r\n bonusIndex,\r\n partsCount\r\n );\r\n }\r\n}\r\n\r\nfunction onWrongClicked(\r\n ev?: React.MouseEvent | React.KeyboardEvent,\r\n item?: IContextualMenuItem\r\n) {\r\n if (item?.data == undefined || !isBuzzMenuItemData(item.data)) {\r\n // We need access to the player and props\r\n return;\r\n }\r\n\r\n const { props, player } = { ...item.data };\r\n\r\n if (item.checked) {\r\n props.cycle.removeWrongBuzz(player, props.appState.game.gameFormat);\r\n } else if (item.checked === false) {\r\n const marker: IBuzzMarker = {\r\n isLastWord: props.isLastWord,\r\n player,\r\n position: props.wordIndex,\r\n points: 0,\r\n };\r\n\r\n // If we're at the end of the question, or if there's already been a neg from a different team, then make it a\r\n // no penalty buzz\r\n const pointsAtPosition: number = props.tossup.getPointsAtPosition(\r\n props.appState.game.gameFormat,\r\n props.wordIndex,\r\n false\r\n );\r\n if (pointsAtPosition < 0) {\r\n const negBuzz: ITossupAnswerEvent | undefined = props.cycle.wrongBuzzes?.find(\r\n (buzz) => buzz.marker.points < 0\r\n );\r\n if (negBuzz == undefined || negBuzz.marker.player.teamName === player.teamName) {\r\n marker.points = pointsAtPosition;\r\n }\r\n }\r\n\r\n props.cycle.addWrongBuzz(marker, props.tossupNumber - 1, props.appState.game.gameFormat);\r\n }\r\n}\r\n\r\nfunction onProtestClicked(\r\n ev?: React.MouseEvent | React.KeyboardEvent,\r\n item?: IContextualMenuItem\r\n) {\r\n if (item?.data == undefined || !isBuzzMenuItemData(item.data)) {\r\n // We need access to the player and props\r\n return;\r\n }\r\n\r\n const { props, player } = { ...item.data };\r\n\r\n if (item.checked) {\r\n props.cycle.removeTossupProtest(player.teamName);\r\n } else if (item.checked === false) {\r\n props.appState.uiState.setPendingTossupProtest(player.teamName, props.tossupNumber - 1, props.wordIndex);\r\n }\r\n}\r\n\r\nfunction isBuzzMenuItemData(data: IBuzzMenuItemData | undefined): data is IBuzzMenuItemData {\r\n return data?.props !== undefined && data.player !== undefined;\r\n}\r\n\r\nexport interface IBuzzMenuProps {\r\n appState: AppState;\r\n bonusIndex: number;\r\n cycle: Cycle;\r\n isLastWord: boolean;\r\n wordIndex: number;\r\n target: React.MutableRefObject;\r\n tossup: Tossup;\r\n tossupNumber: number;\r\n}\r\n\r\ninterface IBuzzMenuItemData {\r\n props: IBuzzMenuProps;\r\n player: Player;\r\n}\r\n","import * as React from \"react\";\r\nimport { observer } from \"mobx-react-lite\";\r\n\r\nimport * as FormattedTextParser from \"../parser/FormattedTextParser\";\r\nimport { IFormattedText } from \"../parser/IFormattedText\";\r\nimport { FormattedText } from \"./FormattedText\";\r\nimport { StateContext } from \"../contexts/StateContext\";\r\nimport { AppState } from \"../state/AppState\";\r\n\r\nexport const Answer = observer(function Answer(props: IAnswerProps): JSX.Element {\r\n const appState: AppState = React.useContext(StateContext);\r\n const formattedText: IFormattedText[] = FormattedTextParser.parseFormattedText(props.text.trimLeft(), {\r\n pronunciationGuideMarkers: appState.game.gameFormat.pronunciationGuideMarkers,\r\n });\r\n\r\n return (\r\n
\r\n ANSWER: \r\n \r\n
\r\n );\r\n});\r\n\r\nexport interface IAnswerProps {\r\n text: string;\r\n className?: string;\r\n disabled?: boolean;\r\n}\r\n","import { AppState } from \"../../state/AppState\";\r\nimport { Cycle } from \"../../state/Cycle\";\r\nimport { ITossupProtestEvent } from \"../../state/Events\";\r\n\r\nexport function commit(cycle: Cycle): void {\r\n const appState: AppState = AppState.instance;\r\n const pendingProtestEvent: ITossupProtestEvent | undefined = appState.uiState.pendingTossupProtestEvent;\r\n if (pendingProtestEvent) {\r\n cycle.addTossupProtest(\r\n pendingProtestEvent.teamName,\r\n pendingProtestEvent.questionIndex,\r\n pendingProtestEvent.position,\r\n pendingProtestEvent.givenAnswer,\r\n pendingProtestEvent.reason\r\n );\r\n appState.uiState.resetPendingTossupProtest();\r\n }\r\n}\r\n\r\nexport function cancel(): void {\r\n AppState.instance.uiState.resetPendingTossupProtest();\r\n}\r\n","import * as React from \"react\";\r\nimport { ContextualMenu, Dialog, DialogType, IDialogContentProps, IDialogProps, IModalProps } from \"@fluentui/react\";\r\nimport { observer } from \"mobx-react-lite\";\r\n\r\nimport { ModalVisibilityStatus } from \"../../state/ModalVisibilityStatus\";\r\nimport { StateContext } from \"../../contexts/StateContext\";\r\nimport { AppState } from \"../../state/AppState\";\r\n\r\nconst modalProps: IModalProps = {\r\n isBlocking: false,\r\n dragOptions: {\r\n moveMenuItemText: \"Move\",\r\n closeMenuItemText: \"Close\",\r\n menu: ContextualMenu,\r\n },\r\n topOffsetFixed: true,\r\n};\r\n\r\nexport const ModalDialog = observer(function ModalDialog(\r\n props: React.PropsWithChildren\r\n): JSX.Element {\r\n const appState: AppState = React.useContext(StateContext);\r\n\r\n const content: IDialogContentProps = {\r\n type: DialogType.normal,\r\n title: props.title,\r\n closeButtonAriaLabel: \"Close\",\r\n showCloseButton: true,\r\n styles: {\r\n innerContent: {\r\n display: \"flex\",\r\n flexDirection: \"column\",\r\n },\r\n },\r\n };\r\n\r\n return (\r\n