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(
+
+
+
+ );
+ }}
+
+ );
+});
+
+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 (
+
;
+}
+
+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 });