Repository: cuttle-cards/cuttle (js)

Issue:
[Feature]: Implement sevenPoints() in the GameState API
### Feature Summary

We need to implement playing Points via a seven in the GameState API in order to support the full game logic in this API. See the [spec](https://docs.google.com/document/d/1vYe4xoVULIbbFU2yhErok-Qm1cg8ujMGNqlUOgNXLwE/edit#heading=h.wpeuh0qc6ps2) for a general overview of the API, its underlying motivations, and the development approach.

### Detailed Description

Implementation should take the general approach shown in the [Backend Control Flow section of the GameState API Spec](https://docs.google.com/document/d/1vYe4xoVULIbbFU2yhErok-Qm1cg8ujMGNqlUOgNXLwE/edit#heading=h.vgf103gono5r) and follow the example of issue https://github.com/cuttle-cards/cuttle/issues/942.

The endpoint should be:

POST /api/game/:gameId/move/seven/points
and it should expect a request body of the following shape:
```
{ 
  playedCardId: '6H', // playing the Six of Hearts for points from the top of the deck
}; 
```
This should verify that the move is legal (that it's your turn, a seven is resolving, the specified card is in the top 2 of the deck), then play the card for points and clean up, as expected.

In addition to building the endpoint, the frontend consumption of it in the src/stores/game.js file's `requestScuttle()` function should be updated to make a request to this endpoint (instead of throwing an error) when the `process.env.VITE_USE_GAME_STATE_API` var equals `"true"`.

The same should be applied to the `playPointsFromSevenOpponent()` test command in `tests/e2e/support/commands.js` when the `CYPRESS_USE_GAMESTATE_API` env var is set to `"true"`. If so, (instead of erroring), it should send a request to the new api endpoint.

Pull request description:
Feat/resolve seven gamestate
<!-- Thanks for contributing to Cuttle! 🎉 -->
Implements the general setup for multi-phase one-offs and the `sevenPoints` move for playing points from the top of the deck.
## Issue number

Relevant [issue number](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- Resolves #957 

## 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/helpers/gamestate/moves/resolve/execute.js b/api/helpers/gamestate/moves/resolve/execute.js
index 1b355ba6..81f3075a 100644
--- a/api/helpers/gamestate/moves/resolve/execute.js
+++ b/api/helpers/gamestate/moves/resolve/execute.js
@@ -32,8 +32,8 @@ module.exports = {
 
     const { oneOff } = result;
 
-    // Move oneOff + twos into scrap and increment turn
     const fizzles = result.twos.length % 2 === 1;
+    // Move oneOff + twos into scrap and increment turn
     result.scrap.push(result.oneOff);
     result.scrap.push(...result.twos);
     result = {
@@ -59,6 +59,19 @@ module.exports = {
       case 1:
         result = sails.helpers.gamestate.moves.resolve.ace(result);
         break;
+      case 3:
+      case 4:
+      case 5:
+      case 7:
+        // These one-offs require an additional user input after resolving
+        // Each one has its own phase designated by the rank
+        result = {
+          ...result,
+          phase: oneOff.rank,
+          oneOff, // oneOff stays on the stack until next move
+          turn: result.turn - 1, // turn doesn't increment until the next move
+        };
+        break;
       default:
         return exits.error(new Error(`${oneOff.rank} is not a valid one-off rank`));
     }
diff --git a/api/helpers/gamestate/moves/seven-points/execute.js b/api/helpers/gamestate/moves/seven-points/execute.js
new file mode 100644
index 00000000..34c849bc
--- /dev/null
+++ b/api/helpers/gamestate/moves/seven-points/execute.js
@@ -0,0 +1,53 @@
+const GamePhase = require('../../../../../utils/GamePhase.json');
+
+module.exports = {
+  friendlyName: 'Play Points from seven',
+
+  description: 'Returns new GameState resulting from requested move to play one of the top two cards of the deck as points (from a seven one-off)',
+
+  inputs: {
+    currentState: {
+      type: 'ref',
+      description: 'The latest GameState before the requesting player plays a card for points from a seven',
+      required: true,
+    },
+    /**
+     * @param { Object } requestedMove - Object describing the request to play points (req.body)
+     * @param { MoveType.SEVEN_POINTS } requestedMove.moveType - Specifies that this a sevenPoints move
+     * @param { String } requestedMove.cardId - Card Played for points
+     */
+    requestedMove: {
+      type: 'ref',
+      description: 'The move being requested. Specifies moveType and which card is being played for points',
+    },
+    playedBy: {
+      type: 'number',
+      description: 'Player number of player requesting move.',
+    },
+  },
+  sync: true, // synchronous helper
+  fn: ({ currentState, requestedMove, playedBy }, exits) => {
+    const { cardId } = requestedMove;
+    let result = _.cloneDeep(currentState);
+
+    const player = playedBy ? result.p1 : result.p0;
+    const cardIndex = result.deck.findIndex(({ id }) => id === cardId);
+    const [ playedCard ] = result.deck.splice(cardIndex, 1);
+    const { oneOff } = result;
+
+    player.points.push(playedCard);
+    result.turn++;
+
+    result = {
+      ...result,
+      ...requestedMove,
+      phase: GamePhase.MAIN,
+      playedBy,
+      playedCard,
+      resolved: oneOff,
+      oneOff: null,
+    };
+
+    return exits.success(result);
+  },
+};
diff --git a/api/helpers/gamestate/moves/seven-points/validate.js b/api/helpers/gamestate/moves/seven-points/validate.js
new file mode 100644
index 00000000..3c4be01e
--- /dev/null
+++ b/api/helpers/gamestate/moves/seven-points/validate.js
@@ -0,0 +1,57 @@
+const GamePhase = require('../../../../../utils/GamePhase.json');
+
+module.exports = {
+  friendlyName: 'Validate sevenPoints',
+
+  description: 'Verifies whether a request to play points from the top of the deck when resolving a seven is legal, throwing explanatory error if not.',
+
+  inputs: {
+    currentState: {
+      type: 'ref',
+      descriptions: 'Object containing the current game state',
+      required: true,
+    },
+    /**
+     * @param { Object } requestedMove - Object describing the request to play points via seven
+     * @param { String } requestedMove.cardId - Card Played for points
+     * @param { MoveType.RESOLVE_SEVEN } requestedMove.moveType - Specifies that this a sevenPoints move
+     */
+    requestedMove: {
+      type: 'ref',
+      description: 'Object containing data needed for current move',
+      required: true,
+    },
+    playedBy: {
+      type: 'number',
+      description: 'Player number of player requesting move',
+      required: true,
+    },
+  },
+  sync: true,
+  fn: ({ requestedMove, currentState, playedBy }, exits) => {
+    try {
+      const topTwoCards = currentState.deck.slice(0, 2);
+      const playedCard = topTwoCards.find(({ id }) => id === requestedMove.cardId);
+
+      if (currentState.turn % 2 !== playedBy) {
+        throw new Error('game.snackbar.global.notYourTurn');
+      }
+
+      if (currentState.phase !== GamePhase.RESOLVING_SEVEN) {
+        throw new Error('game.snackbar.seven.pickAndPlay');
+      }
+
+      if (!playedCard) {
+        throw new Error('game.snackbar.seven.pickAndPlay');
+      }
+
+      if (playedCard.rank > 10) {
+        throw new Error('game.snackbar.points.numberOnlyForPoints');
+      }
+
+      return exits.success();
+    } catch (err) {
+      return exits.error(err);
+    }
+  },
+};
diff --git a/api/helpers/gamestate/publish-game-state.js b/api/helpers/gamestate/publish-game-state.js
index 63cd3fb5..fd0d91a9 100644
--- a/api/helpers/gamestate/publish-game-state.js
+++ b/api/helpers/gamestate/publish-game-state.js
@@ -44,7 +44,14 @@ module.exports = {
 
       //Last Event, and Extra Socket Variables
       const happened = gameState.moveType !== MoveType.FIZZLE;
-      const { playedBy } = gameState;
+      let { playedBy } = gameState;
+
+      // When resolving, the player who resolves is the opponent
+      // of the one who played the one-off (if the one-off doesn't fizzle)
+      if ( gameState.moveType === MoveType.RESOLVE ) {
+        playedBy = (playedBy + 1) % 2;
+      }
+
       const discardedCards = gameState.discardedCards.length ? gameState.discardedCards : null;
       const chosenCard = gameState.moveType === MoveType.RESOLVE_THREE ? gameState.targetCard : null;
       const pNum = playedBy;
diff --git a/api/policies/hasValidMoveBody.js b/api/policies/hasValidMoveBody.js
index 7e546af3..407f5a09 100644
--- a/api/policies/hasValidMoveBody.js
+++ b/api/policies/hasValidMoveBody.js
@@ -21,6 +21,7 @@ module.exports = function (req, res, next) {
 
     case MoveType.POINTS:
     case MoveType.FACECARD:
+    case MoveType.SEVEN_POINTS:
     case MoveType.COUNTER: {
       if (!cardId) {
         return res.badRequest({ message: 'Must specify a card' });


Test patch:
diff --git a/tests/e2e/specs/in-game/one-offs/7_sevens/opponent_sevens.spec.js b/tests/e2e/specs/in-game/one-offs/7_sevens/opponent_sevens.spec.js
index 513191bf..0abb10ab 100644
--- a/tests/e2e/specs/in-game/one-offs/7_sevens/opponent_sevens.spec.js
+++ b/tests/e2e/specs/in-game/one-offs/7_sevens/opponent_sevens.spec.js
@@ -6,8 +6,8 @@ describe('Opponent playing SEVENS', () => {
   beforeEach(() => {
     cy.setupGameAsP1();
   });
+
   it('Opponent plays points from seven', () => {
-    cy.skipOnGameStateApi();
     cy.loadGameFixture(1, {
       p0Hand: [Card.SEVEN_OF_CLUBS],
       p0Points: [],
diff --git a/tests/e2e/specs/in-game/one-offs/7_sevens/player_sevens.spec.js b/tests/e2e/specs/in-game/one-offs/7_sevens/player_sevens.spec.js
index 5518d2ea..ee89487b 100644
--- a/tests/e2e/specs/in-game/one-offs/7_sevens/player_sevens.spec.js
+++ b/tests/e2e/specs/in-game/one-offs/7_sevens/player_sevens.spec.js
@@ -8,7 +8,6 @@ describe('Playing SEVENS', () => {
   });
 
   it('Plays points from a seven', () => {
-    cy.skipOnGameStateApi();
     cy.loadGameFixture(0, {
       p0Hand: [Card.SEVEN_OF_CLUBS],
       p0Points: [],
diff --git a/tests/unit/fixtures/gameStates/resolveNine.js b/tests/unit/fixtures/gameStates/resolveNine.js
index bdda9d3e..f778e1cc 100644
--- a/tests/unit/fixtures/gameStates/resolveNine.js
+++ b/tests/unit/fixtures/gameStates/resolveNine.js
@@ -64,8 +64,8 @@ export const resolveNine = {
     change: MoveType.RESOLVE,
     happened: true,
     oneOff: Card.NINE_OF_CLUBS,
-    pNum: 0,
-    playedBy: 0,
+    pNum: 1,
+    playedBy: 1,
     targetCardId: 'JC',
     game: {
       chat: [],
@@ -75,7 +75,7 @@ export const resolveNine = {
         change: MoveType.RESOLVE,
         happened: true,
         oneOff: Card.NINE_OF_CLUBS,
-        pNum: 0,
+        pNum: 1,
       },
       lock: null,
       lockedAt: null,