diff --git a/package.json b/package.json index 3210321..82c01d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modaq", - "version": "1.18.0", + "version": "1.19.0", "description": "Quiz Bowl Reader using TypeScript, React, and MobX", "repository": { "type": "git", diff --git a/src/components/EventViewer.tsx b/src/components/EventViewer.tsx index 7cdff2a..214f75b 100644 --- a/src/components/EventViewer.tsx +++ b/src/components/EventViewer.tsx @@ -128,13 +128,13 @@ function onRenderItemColumn(item: Cycle, appState: AppState, index: number, colu return ; case cycleKey: - const scores: [number, number][] = column.data; - const scoreInCurrentCycle: [number, number] = scores[index]; + const scores: number[][] = column.data; + const scoreInCurrentCycle: number[] = scores[index]; return ( <> - {`(${scoreInCurrentCycle[0]} - ${scoreInCurrentCycle[1]})`} + {`(${scoreInCurrentCycle.join(" - ")})`} ); default: diff --git a/src/components/GameBar.tsx b/src/components/GameBar.tsx index 331067d..260fd76 100644 --- a/src/components/GameBar.tsx +++ b/src/components/GameBar.tsx @@ -368,7 +368,7 @@ function getViewSubMenuItems(appState: AppState): ICommandBarItemProps[] { items = items.concat([ { - key: "viewDivider", + key: "viewDivider1", itemType: ContextualMenuItemType.Divider, }, { @@ -382,6 +382,21 @@ function getViewSubMenuItems(appState: AppState): ICommandBarItemProps[] { }, ]); + items = items.concat([ + { + key: "viewDivider2", + itemType: ContextualMenuItemType.Divider, + }, + { + key: "scoresheet", + text: "Scoresheet...", + disabled: appState.game.cycles.length === 0, + onClick: () => { + appState.uiState.dialogState.showScoresheetDialog(); + }, + }, + ]); + return items; } diff --git a/src/components/ModalDialogContainer.tsx b/src/components/ModalDialogContainer.tsx index b70da79..d7d18ad 100644 --- a/src/components/ModalDialogContainer.tsx +++ b/src/components/ModalDialogContainer.tsx @@ -13,6 +13,7 @@ import { AddQuestionsDialog } from "./dialogs/AddQuestionsDialog"; import { MessageDialog } from "./dialogs/MessageDialog"; import { RenamePlayerDialog } from "./dialogs/RenamePlayerDialog"; import { ReorderPlayerDialog } from "./dialogs/ReorderPlayerDialog"; +import { ScoresheetDialog } from "./dialogs/ScoresheetDialog"; export const ModalDialogContainer = observer(function ModalDialogContainer() { // The Protest dialogs aren't here because they require extra information @@ -31,6 +32,7 @@ export const ModalDialogContainer = observer(function ModalDialogContainer() { + ); }); diff --git a/src/components/Scoreboard.tsx b/src/components/Scoreboard.tsx index 4d51ba7..1262690 100644 --- a/src/components/Scoreboard.tsx +++ b/src/components/Scoreboard.tsx @@ -15,7 +15,7 @@ export const Scoreboard = observer(function Scoreboard() { const appState: AppState = React.useContext(StateContext); const classes: IScoreboardStyle = getClassNames(); - const scores: [number, number] = appState.game.finalScore; + const scores: number[] = appState.game.finalScore; const teamNames = appState.game.teamNames; const result = teamNames.length >= 2 ? `${teamNames[0]}: ${scores[0]}, ${teamNames[1]}: ${scores[1]}` : ""; diff --git a/src/components/dialogs/ScoresheetDialog.tsx b/src/components/dialogs/ScoresheetDialog.tsx new file mode 100644 index 0000000..fa8c4f2 --- /dev/null +++ b/src/components/dialogs/ScoresheetDialog.tsx @@ -0,0 +1,490 @@ +import * as React from "react"; +import { observer } from "mobx-react-lite"; + +import * as CompareUtils from "../../state/CompareUtils"; +import * as FormattedTextParser from "../../parser/FormattedTextParser"; +import { Cycle } from "../../state/Cycle"; +import { AppState } from "../../state/AppState"; +import { + Dialog, + DialogFooter, + PrimaryButton, + ContextualMenu, + DialogType, + IDialogContentProps, + IModalProps, + Stack, + StackItem, + ITheme, + mergeStyleSets, + ThemeContext, + memoizeFunction, +} from "@fluentui/react"; +import { StateContext } from "../../contexts/StateContext"; +import { GameState } from "../../state/GameState"; +import { Player } from "../../state/TeamState"; +import { ITossupAnswerEvent } from "../../state/Events"; + +const content: IDialogContentProps = { + type: DialogType.normal, + title: "Scoresheet", + closeButtonAriaLabel: "Close", + showCloseButton: true, + styles: { + innerContent: { + display: "flex", + flexDirection: "column", + }, + }, +}; + +const modalProps: IModalProps = { + isBlocking: false, + dragOptions: { + moveMenuItemText: "Move", + closeMenuItemText: "Close", + menu: ContextualMenu, + }, + topOffsetFixed: true, +}; + +// Based on the scoresheet created by Ryan Rosenberg, like the one here: https://quizbowlstats.com/games/95741 +export const ScoresheetDialog = observer(function ScoresheetDialog(): JSX.Element { + const appState: AppState = React.useContext(StateContext); + const closeDialog = React.useCallback(() => appState.uiState.dialogState.hideScoresheetDialog(), [appState]); + + return ( + + ); +}); + +export const ScoresheetDialogBody = observer(function ScoresheetDialogBody( + props: IScoresheetDialogBodyProps +): JSX.Element { + const appState: AppState = props.appState; + const game: GameState = appState.game; + return ( + + {(theme) => { + const classNames: IScoresheetClassNames = getClassNames(theme); + const totalScoreClassNames = `${classNames.totalScoreCell} ${classNames.tableCell}`; + const tuNumberClassNames = `${classNames.tableCell} ${classNames.tuNumber}`; + + const playerToStatlineMap: Map> = new Map>(); + const teamToPlayerMap: Map = new Map(); + const teamToActivePlayerMap: Map> = new Map>(); + for (const teamName of game.teamNames) { + teamToPlayerMap.set(teamName, []); + teamToActivePlayerMap.set(teamName, game.getActivePlayers(teamName, 0)); + } + + for (const player of game.players) { + // game.teamNames will have every team a player is on, so we know the array exists + (teamToPlayerMap.get(player.teamName) as Player[]).push(player); + playerToStatlineMap.set(player, new Map()); + } + + const cyclesRows: JSX.Element[] = []; + for (let i = 0; i < game.playableCycles.length; i++) { + let isFirstTeamName = true; + const cells: JSX.Element[] = []; + const cycle: Cycle = game.playableCycles[i]; + + for (let j = 0; j < game.teamNames.length; j++) { + const teamName = game.teamNames[j]; + if (!isFirstTeamName) { + // Add TU number + const number: number = i + 1; + cells.push( + + {number} + + ); + } else { + isFirstTeamName = false; + } + + // We initialized this before, we'll always have the array + const teamPlayers: Player[] = teamToPlayerMap.get(teamName) as Player[]; + + // getActivePlayers takes O(n) where n is the cycle, so doing this each time in the loop would be + // quadratic. It's not too big of a hit but we do gain several ms from caching this + // We could make this more efficient in some cases by making sure that the team involved is + // in one of these events, but this shouldn't be too big of a hit + if (cycle.playerJoins || cycle.playerLeaves || cycle.subs) { + teamToActivePlayerMap.set(teamName, game.getActivePlayers(teamName, i)); + } + + // We know this always exists since we set it up before + const activeTeamPlayers: Set = teamToActivePlayerMap.get(teamName) as Set; + + for (const player of teamPlayers) { + cells.push( + renderPlayerCell( + game, + player, + cycle, + activeTeamPlayers, + playerToStatlineMap, + i, + classNames + ) + ); + } + + cells.push(renderBonusCell(cycle, teamName, i, classNames)); + + cells.push( + + {game.scores[i][j]} + + ); + } + + cyclesRows.push( + + {cells} + + ); + } + + cyclesRows.push(renderStatlineRow(game, teamToPlayerMap, playerToStatlineMap, classNames)); + + const teamTitle = game.teamNames.join(" vs. "); + return ( + + +

{teamTitle}

+
+ + + + {renderHeader(game, classNames)} + + {cyclesRows} +
+
+
+ ); + }} +
+ ); +}); + +function getUnformattedAnswer(game: GameState, answer: string): string { + // Ignore alternate answers and remove all formatting from the primary answer + const alternateIndex = answer.indexOf("["); + if (alternateIndex >= 0) { + answer = answer.substring(0, alternateIndex).trim(); + } + + const text = FormattedTextParser.parseFormattedText(answer, game.gameFormat.pronunciationGuideMarkers) + .map((line) => line.text) + .join(""); + + return text; +} + +function renderBonusCell( + cycle: Cycle, + teamName: string, + cycleIndex: number, + classNames: IScoresheetClassNames +): JSX.Element { + if (cycle.bonusAnswer && cycle.bonusAnswer.receivingTeamName === teamName) { + // Go through each part, show check or ✓✗ + const lines = []; + let bonusTotal = 0; + for (let i = 0; i < cycle.bonusAnswer.parts.length; i++) { + const part = cycle.bonusAnswer.parts[i]; + lines.push( + part.points > 0 ? ( + + ✓ + + ) : ( + + ✗ + + ) + ); + bonusTotal += part.points; + } + + lines.push( {bonusTotal}); + + return ( + + {lines} + + ); + } + + return ; +} + +function renderHeader(game: GameState, classNames: IScoresheetClassNames): JSX.Element[] { + // header should be + // first team players; Bonus; Total ; TU ; second team players; Bonus; Total + // Because MODAQ supports non-three part bonuses we need to do checks and Xs in one cell + const headers: JSX.Element[] = []; + for (let i = 0; i < game.teamNames.length; i++) { + const teamName: string = game.teamNames[i]; + const players: Player[] = game.getPlayers(teamName); + for (const player of players) { + headers.push( + + {player.name} + + ); + } + + headers.push( + + Bonus + + ); + headers.push( + + Total + + ); + + // Don't include the question counter on the last row (no teams after it to follow along with) + if (i < game.teamNames.length - 1) { + headers.push( + +

TU

+ + ); + } + } + + return headers; +} + +function renderPlayerCell( + game: GameState, + player: Player, + cycle: Cycle, + activeTeamPlayers: Set, + playerToStatlineMap: Map>, + cycleIndex: number, + classNames: IScoresheetClassNames +): JSX.Element { + // if this is too inefficient (because we check all players for the correct buzz), move to using a map. This means + // we need existing cells that we can overwrite. From testing, this seems to be fine. + if (cycle.correctBuzz && CompareUtils.playersEqual(cycle.correctBuzz.marker.player, player)) { + const correctPoints: number = cycle.correctBuzz.marker.points; + const answer = getUnformattedAnswer(game, game.packet.tossups[cycle.correctBuzz.tossupIndex].answer); + + // We know this exists since we initialized it earlier + const statlineMap = playerToStatlineMap.get(player) as Map; + const pointValueCount = statlineMap.get(correctPoints) ?? 0; + statlineMap.set(correctPoints, pointValueCount + 1); + return ( + + {correctPoints} + + ); + } else if (cycle.wrongBuzzes) { + const wrongBuzz: ITossupAnswerEvent | undefined = cycle.wrongBuzzes.find((buzz) => + CompareUtils.playersEqual(buzz.marker.player, player) + ); + if (wrongBuzz) { + const wrongPoints: number = wrongBuzz.marker.points; + const answer = getUnformattedAnswer(game, game.packet.tossups[wrongBuzz.tossupIndex].answer); + + // We know this exists since we initialized it earlier + const statlineMap = playerToStatlineMap.get(player) as Map; + const pointValueCount = statlineMap.get(wrongPoints) ?? 0; + statlineMap.set(wrongPoints, pointValueCount + 1); + return ( + + {wrongPoints} + + ); + } + } + + let cellClassName: string = classNames.tableCell; + if (!activeTeamPlayers.has(player)) { + cellClassName += " " + classNames.inactivePlayerCell; + } + + return ; +} + +function renderStatlineRow( + game: GameState, + teamToPlayerMap: Map, + playerToStatlineMap: Map>, + classNames: IScoresheetClassNames +): JSX.Element { + const statlineCells: JSX.Element[] = []; + let allowedTuPoints: number[] = [10]; + if (game.gameFormat.negValue < 0) { + allowedTuPoints.push(game.gameFormat.negValue); + } + + if (game.gameFormat.powers) { + // powers is already in descending order, so no need to sort + allowedTuPoints = game.gameFormat.powers.map((marker) => marker.points).concat(allowedTuPoints); + } + + let isFirstTeamName = true; + for (let i = 0; i < game.teamNames.length; i++) { + const teamName = game.teamNames[i]; + if (!isFirstTeamName) { + // Add filler cell for TU column + statlineCells.push( + + END + + ); + } else { + isFirstTeamName = false; + } + + // We initialized this before, we'll always have the array + const teamPlayers: Player[] = teamToPlayerMap.get(teamName) as Player[]; + + let tuTotal = 0; + for (const player of teamPlayers) { + const statlineMap = playerToStatlineMap.get(player) as Map; + let totalPoints = 0; + const statline: number[] = []; + // Order should be based on superpower/power/gets/negs, then the total + for (const value of allowedTuPoints) { + const valueCount: number = statlineMap.get(value) ?? 0; + statline.push(valueCount); + totalPoints += valueCount * value; + } + + statlineCells.push( + + {totalPoints} ({statline.join("/")}) + + ); + tuTotal += totalPoints; + } + + // Bonus total and total score cells + const teamTotal = game.finalScore[i]; + statlineCells.push( + + {teamTotal - tuTotal} + + ); + statlineCells.push( + + {teamTotal} + + ); + } + + // tr needs a class to make the top border solid + return ( + + {statlineCells} + + ); +} + +export interface IScoresheetDialogBodyProps { + appState: AppState; +} + +interface IScoresheetClassNames { + bonusCell: string; + correctBonus: string; + cycleRow: string; + inactivePlayerCell: string; + playerRow: string; + statlineRow: string; + table: string; + tableCell: string; + tableHeader: string; + totalScoreCell: string; + tuLabel: string; + tuNumber: string; + wrongBonus: string; +} + +const getClassNames = memoizeFunction( + (theme: ITheme | undefined): IScoresheetClassNames => + mergeStyleSets({ + bonusCell: { + borderLeft: "1px solid", + borderRight: "1px solid", + }, + correctBonus: { + color: theme ? theme.palette.tealLight : "rbg(0, 128, 128)", + }, + cycleRow: { + borderLeft: "1px solid", + borderRight: "1px solid", + }, + inactivePlayerCell: { + backgroundColor: theme?.palette.neutralPrimary ?? "black", + }, + playerRow: { + borderBottom: "1px solid", + margin: 0, + }, + statlineRow: { + border: "1px solid", + // Have a high top-border to make it clear that this isn't another cycle row + borderTop: "20px solid", + }, + table: { + borderCollapse: "collapse", + }, + tableCell: { + border: "1px dotted", + textAlign: "center", + padding: "0 2px", + }, + tableHeader: { + padding: "0em 0.5em", + }, + totalScoreCell: { + fontWeight: 500, + }, + tuLabel: { + marginBottom: 0, + }, + tuNumber: { + textAlign: "center", + fontWeight: 700, + borderLeft: "1px solid", + borderRight: "1px solid", + }, + wrongBonus: { + color: theme ? theme.palette.red : "rbg(128, 0, 0)", + }, + }) +); diff --git a/src/state/DialogState.ts b/src/state/DialogState.ts index 8ca0fa0..bf685eb 100644 --- a/src/state/DialogState.ts +++ b/src/state/DialogState.ts @@ -36,6 +36,9 @@ export class DialogState { @ignore public reorderPlayersDialog: ReorderPlayersDialogState | undefined; + @ignore + public scoresheetDialogVisisble: boolean; + constructor() { makeAutoObservable(this); @@ -48,6 +51,7 @@ export class DialogState { this.newGameDialogVisible = false; this.renamePlayerDialog = undefined; this.reorderPlayersDialog = undefined; + this.scoresheetDialogVisisble = false; } public hideAddQuestionsDialog(): void { @@ -86,6 +90,10 @@ export class DialogState { this.reorderPlayersDialog = undefined; } + public hideScoresheetDialog(): void { + this.scoresheetDialogVisisble = false; + } + public showAddQuestionsDialog(): void { this.addQuestions = new AddQuestionDialogState(); } @@ -114,6 +122,10 @@ export class DialogState { this.reorderPlayersDialog = new ReorderPlayersDialogState(players); } + public showScoresheetDialog(): void { + this.scoresheetDialogVisisble = true; + } + public showOKMessageDialog(title: string, message: string, onOK?: () => void): void { this.messageDialog = { title, diff --git a/src/state/GameState.ts b/src/state/GameState.ts index b71a292..1eaff69 100644 --- a/src/state/GameState.ts +++ b/src/state/GameState.ts @@ -108,7 +108,7 @@ export class GameState { return teamNames; } - public get finalScore(): [number, number] { + public get finalScore(): number[] { return this.scores[this.playableCycles.length - 1]; } @@ -119,14 +119,25 @@ export class GameState { // Check if the game is tied at the end of regulation and at the end of each overtime period. If it isn't, // return those cycles. - const score: [number, number][] = this.scores; + const score: number[][] = this.scores; for ( let i = this.gameFormat.regulationTossupCount - 1; i < this.cycles.length; i += this.gameFormat.minimumOvertimeQuestionCount ) { - const scoreAtInterval: [number, number] = score[i]; - if (scoreAtInterval[0] !== scoreAtInterval[1]) { + const scoreAtInterval: number[] = score[i]; + let isTied = false; + let maxScore = -Infinity; + for (const teamScore of scoreAtInterval) { + if (teamScore > maxScore) { + maxScore = teamScore; + isTied = false; + } else if (teamScore === maxScore) { + isTied = true; + } + } + + if (!isTied) { return this.cycles.slice(0, i + 1); } } @@ -134,18 +145,19 @@ export class GameState { return this.cycles; } - public get scores(): [number, number][] { - const score: [number, number][] = []; - let firstTeamPreviousScore = 0; - let secondTeamPreviousScore = 0; + public get scores(): number[][] { + const score: number[][] = []; + const previousScores: number[] = Array.from(this.teamNames, () => 0); // We should keep calculating until we're at the end of regulation or there are more tiebreaker questions // needed for (const cycle of this.cycles) { - const scoreChange: [number, number] = this.getScoreChangeFromCycle(cycle); - firstTeamPreviousScore += scoreChange[0]; - secondTeamPreviousScore += scoreChange[1]; - score.push([firstTeamPreviousScore, secondTeamPreviousScore]); + const scoreChange: number[] = this.getScoreChangeFromCycle(cycle); + for (let i = 0; i < scoreChange.length; i++) { + previousScores[i] += scoreChange[i]; + } + + score.push([...previousScores]); } return score; @@ -488,8 +500,8 @@ export class GameState { return true; } - private getScoreChangeFromCycle(cycle: Cycle): [number, number] { - const change: [number, number] = [0, 0]; + private getScoreChangeFromCycle(cycle: Cycle): number[] { + const change: number[] = Array.from(this.teamNames, () => 0); if (cycle.correctBuzz) { const indexToUpdate: number = this.teamNames.indexOf(cycle.correctBuzz.marker.player.teamName); if (indexToUpdate < 0) { diff --git a/tests/ScoreTests.ts b/tests/ScoreTests.ts index d2ff2e7..c55c230 100644 --- a/tests/ScoreTests.ts +++ b/tests/ScoreTests.ts @@ -25,6 +25,22 @@ describe("GameStateTests", () => { const game: GameState = createDefaultGame(); expect(game.scores[0]).to.deep.equal([0, 0]); }); + it("Game with more than two teams", () => { + const game: GameState = new GameState(); + const thirdPlayer: Player = new Player("Charlie", "C", /* isStarter */ true); + game.addPlayers(players.concat(thirdPlayer)); + game.loadPacket(defaultPacket); + game.setGameFormat(GameFormats.StandardPowersMACFGameFormat); + expect(game.scores[0]).to.deep.equal([0, 0, 0]); + + game.cycles[0].addWrongBuzz( + { player: thirdPlayer, points: -5, position: 2, isLastWord: false }, + 0, + game.gameFormat + ); + + expect(game.scores[0]).to.deep.equal([0, 0, -5]); + }); it("Neg with zero-point neg format", () => { const game: GameState = createDefaultGame(); game.setGameFormat({ ...game.gameFormat, negValue: 0 });