diff --git a/server/@types/entities.d.ts b/server/@types/entities.d.ts index a395f481..72d35998 100644 --- a/server/@types/entities.d.ts +++ b/server/@types/entities.d.ts @@ -28,4 +28,9 @@ type Question = { subject: string; }; -export type { Answer, Colour, Player, Question }; +type PlayerScore = { + player: Player; + score: number; +}; + +export type { Answer, Colour, Player, PlayerScore, Question }; diff --git a/server/machines/round.ts b/server/machines/round.ts index 2b295b8b..32bad9c0 100644 --- a/server/machines/round.ts +++ b/server/machines/round.ts @@ -1,10 +1,12 @@ import { assign, setup } from "xstate"; -import type { Question } from "../@types/entities"; +import type { PlayerScore, Question } from "../@types/entities"; import questions from "../data/questions.json"; const context = { questions: questions as Question[], + playerScores: [] as PlayerScore[], selectedQuestion: {} as Question | undefined, + bonusPoints: 0, }; type Context = typeof context; @@ -40,6 +42,17 @@ const roundMachine = setup({ states: { turn: { entry: [{ type: "setQuestion", params: dynamicParamFuncs.setQuestion }], + on: { + turnEnd: { + target: "roundEnd", + // guard: (_, __) => { + // check to see if round end conditions are met + // }, + }, + }, + }, + roundEnd: { + type: "final", }, }, }); diff --git a/server/machines/turn.ts b/server/machines/turn.ts index c6a16ec3..0ed1044a 100644 --- a/server/machines/turn.ts +++ b/server/machines/turn.ts @@ -1,5 +1,6 @@ import { assign, setup } from "xstate"; import type { Answer, Player, Question } from "../@types/entities"; +import { getCorrectSocketIdsFromAnswers } from "../utils/scoringUtils"; const context = { answers: [] as Answer[], @@ -18,6 +19,8 @@ type Events = PlayerSubmitsAnswerEvent; type Input = { selectedQuestion: Question }; +type Output = { correctPlayerSocketIds: Player["socketId"][] }; + const dynamicParamFuncs = { addAnswer: ({ context, @@ -36,6 +39,7 @@ const turnMachine = setup({ context: Context; events: Events; input: Input; + output: Output; }, actions: { addAnswer: assign({ @@ -49,17 +53,10 @@ const turnMachine = setup({ _, params: ReturnType, ) => { - return params.finalAnswers - .filter((answer) => { - if (params.correctAnswer.length !== answer.colours.length) { - return false; - } - - return params.correctAnswer.every((colour) => - answer.colours.includes(colour), - ); - }) - .map((answer) => answer.socketId); + return getCorrectSocketIdsFromAnswers( + params.finalAnswers, + params.correctAnswer, + ); }, }), }, @@ -94,6 +91,9 @@ const turnMachine = setup({ ], }, }, + output: ({ context }) => ({ + correctPlayerSocketIds: context.correctPlayerSocketIds, + }), }); export { turnMachine }; diff --git a/server/models/round.ts b/server/models/round.ts index c4c34918..e15cc271 100644 --- a/server/models/round.ts +++ b/server/models/round.ts @@ -4,6 +4,7 @@ import { context, roundMachine } from "../machines/round"; import { turnMachine } from "../machines/turn"; import type { SocketServer } from "../socketServer"; import { machineLogger } from "../utils/loggingUtils"; +import { getUpdatedPlayerScoresAndBonusPoints } from "../utils/scoringUtils"; class Round { machine: Actor; @@ -43,18 +44,20 @@ class Round { .selectedQuestion as Question, }, }); - this.turnMachine.subscribe((state) => { - switch (state.value) { - case "finished": { - // TODO: - // - add logic for updating scores then checking if there's a clear winner in the round machine - // - delete the console.info below - console.info( - "turn machine finished with context:", - this.turnMachine?.getSnapshot().context, - ); - } - } + + this.turnMachine.subscribe({ + complete: () => { + const roundMachineSnapshot = this.machine.getSnapshot(); + this.machine.send({ + type: "turnEnd", + scoresAndBonusPoints: getUpdatedPlayerScoresAndBonusPoints( + roundMachineSnapshot.context.bonusPoints, + roundMachineSnapshot.context.playerScores, + this.turnMachine?.getSnapshot()?.output?.correctPlayerSocketIds || + [], + ), + }); + }, }); this.turnMachine.start(); } diff --git a/server/utils/scoringUtils.test.ts b/server/utils/scoringUtils.test.ts new file mode 100644 index 00000000..bf29f1ff --- /dev/null +++ b/server/utils/scoringUtils.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "bun:test"; +import type { Colour, Player, PlayerScore } from "../@types/entities"; +import { + getCorrectSocketIdsFromAnswers, + getUpdatedPlayerScoresAndBonusPoints, +} from "./scoringUtils"; + +describe("scoringUtils", () => { + describe("getCorrectSocketIdsFromAnswers", () => { + const correctAnswer: Colour[] = ["red", "blue"]; + const incorrectAnswer: Colour[] = ["pink", "blue"]; + + it("returns the IDs of the players with the correct answers", () => { + expect( + getCorrectSocketIdsFromAnswers( + [ + { colours: correctAnswer, socketId: "1" }, + { colours: incorrectAnswer, socketId: "2" }, + { colours: correctAnswer, socketId: "3" }, + ], + correctAnswer, + ), + ).toEqual(["1", "3"]); + }); + + it("returns an empty array if there are no correct answers", () => { + expect( + getCorrectSocketIdsFromAnswers( + [ + { colours: incorrectAnswer, socketId: "1" }, + { colours: incorrectAnswer, socketId: "2" }, + ], + correctAnswer, + ), + ).toBeArrayOfSize(0); + }); + }); + + describe("getUpdatedPlayerScoresAndBonusPoints", () => { + describe("if all players are correct", () => { + it("increments the bonus points and returns the player scores unchanged", () => { + const currentBonusPoints = 0; + const correctPlayerSocketIds = ["1", "2"]; + const currentPlayerScores: PlayerScore[] = [ + { + player: { name: "olaf", socketId: correctPlayerSocketIds[0] }, + score: 1, + }, + { + player: { name: "alex", socketId: correctPlayerSocketIds[1] }, + score: 0, + }, + ]; + + expect( + getUpdatedPlayerScoresAndBonusPoints( + currentBonusPoints, + currentPlayerScores, + correctPlayerSocketIds, + ), + ).toEqual({ + bonusPoints: 1, + playerScores: currentPlayerScores, + }); + }); + }); + + describe("if all players are incorrect", () => { + it("resets the bonus points and returns the player scores unchanged", () => { + const currentBonusPoints = 3; + const correctPlayerSocketIds: Player["socketId"][] = []; + const currentPlayerScores: PlayerScore[] = [ + { + player: { name: "olaf", socketId: "1" }, + score: 1, + }, + { + player: { name: "alex", socketId: "2" }, + score: 0, + }, + ]; + + expect( + getUpdatedPlayerScoresAndBonusPoints( + currentBonusPoints, + currentPlayerScores, + correctPlayerSocketIds, + ), + ).toEqual({ + bonusPoints: 0, + playerScores: currentPlayerScores, + }); + }); + }); + + describe("if some players are correct and others incorrect", () => { + describe("and there are no bonus points", () => { + it("awards points to the correct players and returns the player scores", () => { + const currentBonusPoints = 2; + const correctPlayerSocketIds = ["1", "3"]; + const currentPlayerScores: PlayerScore[] = [ + { + player: { name: "olaf", socketId: correctPlayerSocketIds[0] }, + score: 0, + }, + { + player: { name: "alex", socketId: "2" }, + score: 0, + }, + { + player: { name: "james", socketId: correctPlayerSocketIds[1] }, + score: 0, + }, + ]; + + expect( + getUpdatedPlayerScoresAndBonusPoints( + currentBonusPoints, + currentPlayerScores, + correctPlayerSocketIds, + ), + ).toEqual({ + bonusPoints: 0, + playerScores: [ + { player: { name: "olaf", socketId: "1" }, score: 3 }, + { player: { name: "alex", socketId: "2" }, score: 0 }, + { player: { name: "james", socketId: "3" }, score: 3 }, + ], + }); + }); + }); + }); + }); +}); diff --git a/server/utils/scoringUtils.ts b/server/utils/scoringUtils.ts new file mode 100644 index 00000000..f48e2dbb --- /dev/null +++ b/server/utils/scoringUtils.ts @@ -0,0 +1,75 @@ +import type { Answer, Player, PlayerScore, Question } from "../@types/entities"; + +const allCorrect = ( + totalPlayerCount: number, + correctPlayerSocketIds: Player["socketId"][], +): boolean => { + return correctPlayerSocketIds.length === totalPlayerCount; +}; + +const allIncorrect = ( + correctPlayerSocketIds: Player["socketId"][], +): boolean => { + return correctPlayerSocketIds.length === 0; +}; + +const getUpdatedPlayerScores = ( + currentPlayerScores: PlayerScore[], + bonusPoints: number, + correctPlayerSocketIds: Player["socketId"][], +): PlayerScore[] => { + const numberOfIncorrectAnswers = + currentPlayerScores.length - correctPlayerSocketIds.length; + const pointsToAward = numberOfIncorrectAnswers + bonusPoints; + + return currentPlayerScores.map(({ player, score }) => { + if (correctPlayerSocketIds.includes(player.socketId)) { + return { player, score: score + pointsToAward }; + } + + return { player, score }; + }); +}; + +const getUpdatedPlayerScoresAndBonusPoints = ( + currentBonusPoints: number, + currentPlayerScores: PlayerScore[], + correctPlayerSocketIds: Player["socketId"][], +): { bonusPoints: number; playerScores: PlayerScore[] } => { + if (allCorrect(currentPlayerScores.length, correctPlayerSocketIds)) { + return { + bonusPoints: currentBonusPoints + 1, + playerScores: currentPlayerScores, + }; + } + + if (allIncorrect(correctPlayerSocketIds)) { + return { bonusPoints: 0, playerScores: currentPlayerScores }; + } + + return { + bonusPoints: 0, + playerScores: getUpdatedPlayerScores( + currentPlayerScores, + currentBonusPoints, + correctPlayerSocketIds, + ), + }; +}; + +const getCorrectSocketIdsFromAnswers = ( + answers: Answer[], + correctAnswer: Question["colours"], +) => { + return answers + .filter((answer) => { + if (correctAnswer.length !== answer.colours.length) { + return false; + } + + return correctAnswer.every((colour) => answer.colours.includes(colour)); + }) + .map((answer) => answer.socketId); +}; + +export { getCorrectSocketIdsFromAnswers, getUpdatedPlayerScoresAndBonusPoints };