From c8bcda9e60db039f07dfd43e697ee5decac175ef Mon Sep 17 00:00:00 2001 From: Ynda Jas Date: Thu, 15 Aug 2024 17:10:28 +0100 Subject: [PATCH 1/4] Abstract correct player method into utils Co-authored-by: Rich James --- server/machines/turn.ts | 16 +++++--------- server/utils/scoringUtils.test.ts | 35 +++++++++++++++++++++++++++++++ server/utils/scoringUtils.ts | 18 ++++++++++++++++ 3 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 server/utils/scoringUtils.test.ts create mode 100644 server/utils/scoringUtils.ts diff --git a/server/machines/turn.ts b/server/machines/turn.ts index c6a16ec3..1541bbef 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[], @@ -49,17 +50,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, + ); }, }), }, diff --git a/server/utils/scoringUtils.test.ts b/server/utils/scoringUtils.test.ts new file mode 100644 index 00000000..e9f76c6a --- /dev/null +++ b/server/utils/scoringUtils.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import type { Colour } from "../@types/entities"; +import { getCorrectSocketIdsFromAnswers } 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); + }); + }); +}); diff --git a/server/utils/scoringUtils.ts b/server/utils/scoringUtils.ts new file mode 100644 index 00000000..f84034a3 --- /dev/null +++ b/server/utils/scoringUtils.ts @@ -0,0 +1,18 @@ +import type { Answer, Question } from "../@types/entities"; + +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 }; From bb8b4240da8b0e23aa7e003cf2f53104e303dee0 Mon Sep 17 00:00:00 2001 From: rich Date: Mon, 19 Aug 2024 15:49:56 +0100 Subject: [PATCH 2/4] Add output to machine This is the correct way to output data from a machine when it reaches its final state. See docs: https://stately.ai/docs/final-states#output --- server/machines/turn.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/machines/turn.ts b/server/machines/turn.ts index 1541bbef..0ed1044a 100644 --- a/server/machines/turn.ts +++ b/server/machines/turn.ts @@ -19,6 +19,8 @@ type Events = PlayerSubmitsAnswerEvent; type Input = { selectedQuestion: Question }; +type Output = { correctPlayerSocketIds: Player["socketId"][] }; + const dynamicParamFuncs = { addAnswer: ({ context, @@ -37,6 +39,7 @@ const turnMachine = setup({ context: Context; events: Events; input: Input; + output: Output; }, actions: { addAnswer: assign({ @@ -88,6 +91,9 @@ const turnMachine = setup({ ], }, }, + output: ({ context }) => ({ + correctPlayerSocketIds: context.correctPlayerSocketIds, + }), }); export { turnMachine }; From 8d12d7eeafd937efe0fabfd1e8641411a541e702 Mon Sep 17 00:00:00 2001 From: Ynda Jas Date: Thu, 15 Aug 2024 17:11:00 +0100 Subject: [PATCH 3/4] Update scores and bonus points at turn end The logic here is relatively complex so worthwhile testing. I have opted to only test the exported functions as the smaller, local ones are very simple. Co-authored-by: Rich James --- server/@types/entities.d.ts | 7 +- server/machines/round.ts | 4 +- server/models/round.ts | 27 ++++---- server/utils/scoringUtils.test.ts | 103 +++++++++++++++++++++++++++++- server/utils/scoringUtils.ts | 61 +++++++++++++++++- 5 files changed, 184 insertions(+), 18 deletions(-) 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..45e52140 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; 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 index e9f76c6a..bf29f1ff 100644 --- a/server/utils/scoringUtils.test.ts +++ b/server/utils/scoringUtils.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "bun:test"; -import type { Colour } from "../@types/entities"; -import { getCorrectSocketIdsFromAnswers } from "./scoringUtils"; +import type { Colour, Player, PlayerScore } from "../@types/entities"; +import { + getCorrectSocketIdsFromAnswers, + getUpdatedPlayerScoresAndBonusPoints, +} from "./scoringUtils"; describe("scoringUtils", () => { describe("getCorrectSocketIdsFromAnswers", () => { @@ -32,4 +35,100 @@ describe("scoringUtils", () => { ).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 index f84034a3..f48e2dbb 100644 --- a/server/utils/scoringUtils.ts +++ b/server/utils/scoringUtils.ts @@ -1,4 +1,61 @@ -import type { Answer, Question } from "../@types/entities"; +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[], @@ -15,4 +72,4 @@ const getCorrectSocketIdsFromAnswers = ( .map((answer) => answer.socketId); }; -export { getCorrectSocketIdsFromAnswers }; +export { getCorrectSocketIdsFromAnswers, getUpdatedPlayerScoresAndBonusPoints }; From 1f086f63f270ccf6d8848a15115a5917a04fd036 Mon Sep 17 00:00:00 2001 From: rich Date: Mon, 19 Aug 2024 15:48:41 +0100 Subject: [PATCH 4/4] Add roundEnd state to roundMachine This is just useful for logging to see if the interactions in the model are working as expected. Depending on the guard we will either play another turn or end the round. --- server/machines/round.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/machines/round.ts b/server/machines/round.ts index 45e52140..32bad9c0 100644 --- a/server/machines/round.ts +++ b/server/machines/round.ts @@ -42,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", }, }, });