Repository: cuttle-cards/cuttle (js)

Issue:
[Feature]: Update `gameService.dealCards()` to use new GameState model
### Feature Summary

We should update the function that deals the cards/initializes the game state so that it utilizes the new GameState model.

### Detailed Description

`gameService.dealCards()` is the utility that initializes the game by creating and placing all the cards. We should update it to read from the `process.env.SAILS_USE_GAME_STATE_API` env var and if that is set to “true”, use a new sails helper to instantiate and save a new `GameState` associated with the specified game. This should make it so that we can start a game via ready and rematch.

Pull request description:
Feat/deal cards gamestate
<!-- Thanks for contributing to Cuttle! 🎉 -->
Implements initializing/dealing the cards using the GameState API


## Issue number

Relevant [issue number](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- Resolves #939 

## Please check the following

- [ ] Do the tests still pass? (see [Run the Tests](https://github.com/cuttle-cards/cuttle/blob/main/docs/setup-development.md#run-the-tests))
- [ ] Is the code formatted properly? (see [Linting (Formatting)](https://github.com/cuttle-cards/cuttle/blob/main/docs/setup-development.md#linting-formatting))
- For New Features:
  - [ ] Have tests been added to cover any new features or fixes?
  - [ ] Has the documentation been updated accordingly?

## Please describe additional details for testing this change


Patch:
diff --git a/api/controllers/game/get-list.js b/api/controllers/game/get-list.js
index decddcac..3daf9a22 100644
--- a/api/controllers/game/get-list.js
+++ b/api/controllers/game/get-list.js
@@ -28,7 +28,7 @@ module.exports = async function (req, res) {
         Game.publish(
           [req.session.game],
           {
-            change: 'initialize',
+            change: 'deal',
             pNum: req.session.pNum,
             game: game,
           },
diff --git a/api/controllers/game/leave-lobby.js b/api/controllers/game/leave-lobby.js
index c1acdec1..1c5454c6 100644
--- a/api/controllers/game/leave-lobby.js
+++ b/api/controllers/game/leave-lobby.js
@@ -10,8 +10,10 @@ module.exports = function (req, res) {
       };
       if (player.pNum === 0) {
         gameUpdates.p0Ready = false;
+        gameUpdates.p0 = null;
       } else {
         gameUpdates.p1Ready = false;
+        gameUpdates.p1 = null;
       }
 
       // Unsubscribe user from updates to this game
diff --git a/api/controllers/game/ready.js b/api/controllers/game/ready.js
index 1cf14e00..1f594796 100644
--- a/api/controllers/game/ready.js
+++ b/api/controllers/game/ready.js
@@ -31,7 +31,9 @@ module.exports = async function (req, res) {
       sails.sockets.blast('gameStarted', { gameId: game.id });
       gameUpdates.status = gameService.GameStatus.STARTED;
       // Deal cards (also emits socket event)
-      await gameService.dealCards(game, gameUpdates);
+      await Game.updateOne({ id: game.id }).set(gameUpdates);
+
+      await gameService.dealCards({...game, ...gameUpdates}, {});
 
     // Otherwise send socket message that player is ready
     } else {
@@ -51,6 +53,7 @@ module.exports = async function (req, res) {
 
     return res.ok();
   } catch (err) {
-    return res.badRequest(err);
+    const message = err.raw?.message ?? err;
+    return res.badRequest({ message });
   }
 };
diff --git a/api/controllers/game/rematch.js b/api/controllers/game/rematch.js
index a17bc63d..077bf844 100644
--- a/api/controllers/game/rematch.js
+++ b/api/controllers/game/rematch.js
@@ -15,12 +15,12 @@ module.exports = async function (req, res) {
     let game =  await sails.helpers.lockGame(req.session.game);
 
     // Early return if requesting user was not in the game
-    if (![game.p0, game.p1].includes(userId)) {
+    if (!userId || ![game.p0?.id, game.p1?.id].includes(userId)) {
       return;
     }
 
     // Determine whether to start new game
-    const oldPNum = game.p0 === userId ? 0 : 1;
+    const oldPNum = game.p0.id === userId ? 0 : 1;
     const rematchVal = { [`p${oldPNum}Rematch`]: rematch };
     const gameUpdates = {
       ...rematchVal,
@@ -48,7 +48,7 @@ module.exports = async function (req, res) {
     // Get all exisiting rematchGames to compute new game name & isRanked
     const [ rematchGames, players, currentMatch ] = await Promise.all([
       sails.helpers.getRematchGames(game),
-      User.find({id: [game.p0, game.p1]}),
+      User.find({id: [game.p0.id, game.p1.id]}),
       Match.findOne({ id: game.match })
     ]);
     // Determine who was p0 and p1 in first game in the series
@@ -75,12 +75,12 @@ module.exports = async function (req, res) {
 
     // Update old game's rematchGame & add players to new game
     gameUpdates.rematchGame = newGame.id;
-    const { p0: newP1Id, p1: newP0Id } = game;
+    const { p0: newP1, p1: newP0 } = game;
     const [ updatedGame, p0, p1] = await Promise.all([
       Game.updateOne({ id: game.id }).set(gameUpdates),
-      User.updateOne({ id: newP0Id }).set({ pNum: 0 }),
-      User.updateOne({ id: newP1Id }).set({ pNum: 1 }),
-      Game.replaceCollection(newGame.id, 'players').members([newP0Id, newP1Id]),
+      User.updateOne({ id: newP0.id }).set({ pNum: 0 }),
+      User.updateOne({ id: newP1.id }).set({ pNum: 1 }),
+      Game.replaceCollection(newGame.id, 'players').members([newP0.id, newP1.id]),
     ]);
 
     // Deal cards in new game
@@ -99,6 +99,7 @@ module.exports = async function (req, res) {
     await sails.helpers.unlockGame(game.lock);
     return res.ok({ newGameId: newGame.id });
   } catch (err) {
-    return res.ok();
+    const message = err.raw?.message ?? err;
+    return res.badRequest({ message });
   }
 };
diff --git a/api/controllers/game/subscribe.js b/api/controllers/game/subscribe.js
index df086b1d..4b917220 100644
--- a/api/controllers/game/subscribe.js
+++ b/api/controllers/game/subscribe.js
@@ -40,7 +40,14 @@ module.exports = function (req, res) {
       const addPlayerToGame = Game.addToCollection(game.id, 'players').members([user.id]);
       const updatePlayer = User.updateOne({ id: user.id }).set({ pNum });
 
-      return Promise.all([game, updatePlayer, addPlayerToGame]);
+      // Set game.p0 or game.p1 as requesting user's id
+      const pNumUpdate = {};
+      const pNumKey = `p${pNum}`;
+      pNumUpdate[pNumKey] = user.id;
+      game[pNumKey] = user.id;
+      const updateGame = Game.updateOne({id: game.id}).set(pNumUpdate);
+
+      return Promise.all([game, updatePlayer, addPlayerToGame, updateGame]);
     })
     .then(function respond(values) {
       const [game, user] = values;
diff --git a/api/helpers/gamestate/deal-cards.js b/api/helpers/gamestate/deal-cards.js
new file mode 100644
index 00000000..06a043d9
--- /dev/null
+++ b/api/helpers/gamestate/deal-cards.js
@@ -0,0 +1,80 @@
+const GameStatus = require('../../../utils/GameStatus.json');
+const DeckIds = require('../../../utils/DeckIds.json');
+const MoveType = require('../../../utils/MoveType.json');
+const GamePhase = require('../../../utils/GamePhase.json');
+
+module.exports = {
+  friendlyName: 'Deal Cards',
+
+  description: 'Creates initial GameStateRow for specified game, with cards dealt ot both players (5 for p0 and 6 for p1). Errors if Game has previous game or incorrec status.',
+
+  inputs: {
+    game: {
+      type: 'ref',
+      description: 'Game record to be initialized with first GameState',
+      extendedDescription: 'Game is expected to have populated collection of .gameStates',
+      required: true,
+    },
+  },
+
+  fn: async ({ game }, exits) => {
+    
+    try {
+
+      if (game.status !== GameStatus.STARTED) {
+        return exits.error({ message: 'Game has not yet started or is over' });
+      }
+
+      if (!game.gameStates) {
+        return exits.error({ message: 'Game was not populated with .gameStates collection' });
+      }
+
+      if (game.gameStates.length) {
+        return exits.error({ message: 'Cards are already dealt' });
+      }
+
+      const deck = _.shuffle(
+        DeckIds.map((cardId) => sails.helpers.gamestate.convertStrToCard(cardId))
+      );
+
+      const p0 = {
+        hand: deck.splice(0, 5),
+        points: [],
+        faceCards: [],
+      };
+
+      const p1 = {
+        hand: deck.splice(0, 6),
+        points: [],
+        faceCards: [],
+      };
+
+      const newGameState = {
+        gameId: game.id,
+        moveType: MoveType.DEAL,
+        turn: 0,
+        phase: GamePhase.MAIN,
+        p0,
+        p1,
+        deck,
+        scrap: [],
+        twos: [],
+        playedBy: 1, // p1 "deals"
+        playedCard: null,
+        targetCardId: null,
+        discardedCards: [],
+        oneOff: null,
+        oneOffTarget: null,
+        resolving: null,
+      };
+
+      const { saveGamestate, publishGameState } = sails.helpers.gamestate;
+      await saveGamestate(newGameState);
+      await publishGameState(game, newGameState);
+
+      return exits.success(newGameState);
+    } catch (err) {
+      return exits.error(err);
+    }
+  },
+};
diff --git a/api/helpers/gamestate/validate-gamestate.js b/api/helpers/gamestate/validate-gamestate.js
index 23b1c8ed..1442f101 100644
--- a/api/helpers/gamestate/validate-gamestate.js
+++ b/api/helpers/gamestate/validate-gamestate.js
@@ -33,8 +33,8 @@ function validatePhase(param) {
 }
 
 function validatePlayedBy(param) {
-  if (param === null || param === undefined || Number.isNaN(param) || param < 0 || param > 1) {
-    throw new Error('The playedBy attribute cannot be null and must be a number in [0,1]');
+  if (![0, 1].includes(param)) {
+    throw new Error('The playedBy attribute must be a number in [0,1]');
   }
   return param;
 }
diff --git a/api/helpers/get-rematch-games.js b/api/helpers/get-rematch-games.js
index 9a18b8f7..9a2e0183 100644
--- a/api/helpers/get-rematch-games.js
+++ b/api/helpers/get-rematch-games.js
@@ -1,6 +1,13 @@
 // Pull the logic into func outside fn to allow recursion w/o bumping into weirdness w/ sails' exits
 async function getRematchGames (game, priorRematchGames = []) {
-  const rematchGames = [game, ...priorRematchGames];
+  const rematchGames = [game, ...priorRematchGames].map((matchGame) => {
+    return {
+      ...matchGame,
+      // Get p0 and p1 id, whether populated or not
+      p0: matchGame.p0?.id ?? matchGame.p0,
+      p1: matchGame.p1?.id ?? matchGame.p1,
+    };
+  });
   const gameToAdd = await Game.findOne({ rematchGame: game.id });
 
   if (!gameToAdd) {
diff --git a/api/helpers/lock-game.js b/api/helpers/lock-game.js
index cf33ff66..2bb6a41a 100644
--- a/api/helpers/lock-game.js
+++ b/api/helpers/lock-game.js
@@ -34,12 +34,18 @@ module.exports = {
         const now = dayjs.utc().toDate();
         const lockIsStaleTimeout = dayjs.utc().subtract(30, 'second').toDate();
 
-        // Lock & re-fetch game if unlocked or lock is expired
-        const updatedGame = await Game.updateOne({
+        // Lock game if unlocked or lock is expired
+        await Game.updateOne({
           id: gameId,
           or: [{ lock: null }, { lockedAt: { '<=': lockIsStaleTimeout } }],
         }).set({ lock: uuId, lockedAt: now });
 
+        // Re-fetch game and populate players and gamestates
+        const updatedGame = await Game.findOne({ id: gameId })
+          .populate('p0')
+          .populate('p1')
+          .populate('gameStates', { sort: 'createdAt ASC' });
+
         // If we successfully wrote our uuid, resolve
         const newLock = updatedGame?.lock;
         if (newLock === uuId) {
diff --git a/api/services/gameService.js b/api/services/gameService.js
index 48a6e6c4..06168fc2 100644
--- a/api/services/gameService.js
+++ b/api/services/gameService.js
@@ -232,6 +232,11 @@ module.exports = {
   },
 
   dealCards: function (game, gameUpdates) {
+    // Use gamestate api if feature flag is set, otherwise legacy
+    if (process.env.VITE_USE_GAMESTATE_API) {
+      return sails.helpers.gamestate.dealCards(game);
+    }
+
     return new Promise(function makeDeck(resolveMakeDeck) {
       const p0 = game.players.find((player) => player.pNum === 0);
       const p1 = game.players.find((player) => player.pNum === 1);
@@ -263,7 +268,7 @@ module.exports = {
         gameUpdates.topCard = shuffledDeck.shift();
         gameUpdates.secondCard = shuffledDeck.shift();
         gameUpdates.lastEvent = {
-          change: 'initialize',
+          change: 'deal',
         };
         gameUpdates.p0 = p0.id;
         gameUpdates.p1 = p1.id;
@@ -291,7 +296,7 @@ module.exports = {
       })
       .then(function publish(fullGame) {
         Game.publish([fullGame.id], {
-          change: 'initialize',
+          change: 'deal',
           game: fullGame,
         });
         return Promise.resolve(fullGame);
diff --git a/src/plugins/sockets/inGameEvents.js b/src/plugins/sockets/inGameEvents.js
index 25d37045..7a608618 100644
--- a/src/plugins/sockets/inGameEvents.js
+++ b/src/plugins/sockets/inGameEvents.js
@@ -34,7 +34,7 @@ export async function handleInGameEvents(evData) {
       gameStore.updateReady(evData.pNum);
       return;
     }
-    case SocketEvent.INITIALIZE: {
+    case SocketEvent.DEAL: {
       // Update state
       if (isSpectating) {
         gameStore.myPNum = 0; // always spectate as p0
diff --git a/types/SocketEvent.js b/types/SocketEvent.js
index b2d6c1a6..fe6843ee 100644
--- a/types/SocketEvent.js
+++ b/types/SocketEvent.js
@@ -1,8 +1,8 @@
 export default {
+  DEAL: 'deal',
   COUNTER: 'counter',
   DRAW: 'draw',
   FACE_CARD: 'faceCard',
-  INITIALIZE: 'initialize',
   JACK: 'jack',
   LOAD_FIXTURE: 'loadFixture',
   ONE_OFF: 'oneOff',
diff --git a/utils/DeckIds.json b/utils/DeckIds.json
new file mode 100644
index 00000000..3665327e
--- /dev/null
+++ b/utils/DeckIds.json
@@ -0,0 +1,6 @@
+[
+  "AC", "2C", "3C", "4C", "5C", "6C", "7C", "8C", "9C", "TC", "JC", "QC", "KC",
+  "AD", "2D", "3D", "4D", "5D", "6D", "7D", "8D", "9D", "TD", "JD", "QD", "KD",
+  "AH", "2H", "3H", "4H", "5H", "6H", "7H", "8H", "9H", "TH", "JH", "QH", "KH",
+  "AS", "2S", "3S", "4S", "5S", "6S", "7S", "8S", "9S", "TS", "JS", "QS", "KS"
+]
diff --git a/utils/MoveType.json b/utils/MoveType.json
index 0ef65601..90549f56 100644
--- a/utils/MoveType.json
+++ b/utils/MoveType.json
@@ -1,5 +1,5 @@
 {
-  "INITIALIZE": "initialize",
+  "DEAL": "deal",
   "DRAW": "draw",
   "POINTS": "points",
   "SCUTTLE": "scuttle",


Test patch:
diff --git a/tests/unit/fixtures/gameStates/points.js b/tests/unit/fixtures/gameStates/points.js
index 406ff0c9..c239cc03 100644
--- a/tests/unit/fixtures/gameStates/points.js
+++ b/tests/unit/fixtures/gameStates/points.js
@@ -137,10 +137,10 @@ export const points = {
             lockedAt: null,
             match: null,
             name: 'Test Game',
-            p0: gameFixture.p0,
+            p0: gameFixture.p0.id,
             p0Ready: true,
             p0Rematch: null,
-            p1: gameFixture.p1,
+            p1: gameFixture.p1.id,
             p1Ready: true,
             p1Rematch: null,
             rematchGame: null,
@@ -151,8 +151,8 @@ export const points = {
             winner: 17,
           },
         ],
-        player1: gameFixture.p0,
-        player2: gameFixture.p1,
+        player1: gameFixture.p0.id,
+        player2: gameFixture.p1.id,
         winner: null,
       },
       winner: 0,
@@ -191,10 +191,10 @@ export const points = {
             lockedAt: null,
             match: null,
             name: 'Test Game',
-            p0: gameFixture.p0,
+            p0: gameFixture.p0.id,
             p0Ready: true,
             p0Rematch: null,
-            p1: gameFixture.p1,
+            p1: gameFixture.p1.id,
             p1Ready: true,
             p1Rematch: null,
             rematchGame: null,
@@ -205,8 +205,8 @@ export const points = {
             winner: 17,
           },
         ],
-        player1: gameFixture.p0,
-        player2: gameFixture.p1,
+        player1: gameFixture.p0.id,
+        player2: gameFixture.p1.id,
         winner: null,
       },
     },