Repository: cuttle-cards/cuttle (js)

Issue:
[Feature]: Implement Playing an untargeted one-off in the GameState API
### Feature Summary

We need to implement playing untargeted one-offs (e.g. aces) 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.

Playing an untargeted one off should result in the `playedCard` being removed from the requesting player's `hand` and added to the GameState's `oneOff`. It should also set the `phase` to `GamePhase.COUNTERING`. It should _not_ increment the turn.

### 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 #1025 

Making this move, like all others, will be triggered via the endpoint:

```
POST /api/game/:gameId/move
```
Which move gets made is determined by the request body, which should specify a moveType field with a value from the MoveType enum. Playing an untargeted one-off will be triggered by sending a request body like:

```
{ moveType: MoveType.UNTARGETED_ONE_OFF, playedCard: 'JH' };
```
When a `moveType: MoveType.UNTARGETED_ONE_OFF` is sent in the req body, the endpoint will attempt to utilize the `untargetedOneOff` helpers: `sails.helpers.gamestate.moves.untargetedOneOff.validate()` and `sails.helpers.gamestate.moves.untargetedOneOff.execute()`. Each of these should follow the paradigm outlined in the [docs](https://docs.google.com/document/d/1vYe4xoVULIbbFU2yhErok-Qm1cg8ujMGNqlUOgNXLwE/edit#heading=h.vgf103gono5r):

There are four pieces to implement:
1. update the `api/policies/hasValidMoveBody` policy to allow and validate requests with a moveType of `MoveType.UNTARGETED_ONE_OFF`
2. Implement the `api/helpers/gamestate/moves/untargetedOneOff/validate` helper to verify if the move is legal
3. Implement the `api/helpers/gamestate/moves/untargetedOneOff/execute` helper to create a new GameState that plays an untargeted one-off for the requested player
4. Unskip the relevant tests by removing the `cy.skipOnOnGameStateApi()` commands from tests which can now pass in the GameState API

### Updating the `api/policies/hasValidMoveBody` policy
We must also update the policy `api/policies/hasValidMoveBody.js` to accommodate the `MoveType.UNTARGETED_ONE_OFF` moveType. This policy is already applied to the endpoint for making a move and it uses a `switch()` statement on `req.body.moveType` to validate that a given req body has all the data necessary for the given `moveType`. We should add a `MoveType.UNTARGETED_ONE_OFF` case to this policy.

A `moveType`  of `MoveType.UNTARGETED_ONE_OFF` requires a `playedCardId` which is a valid card id. If `playedCardId` is present in req.body and it can be found in `utils/DeckIds.json`, then we can `return res.next()` in this switch case. Otherwise we should return `res.badRequest()` with an error that explains which `req.body` attribute is missing or invalid.

### Implementing `validate()`
Create a new `api/helpers/gamestate/moves/untargetedOneOff/validate.js` helper file that matches the general setup of the existing `api/helpers/gamestate/moves/points/validate.js` helper. It should throw errors if the requested move is illegal. It should have three inputs: 
1. `currentState` - the unpacked `GameState` from before the move is made
2. `requestedMove` -req body describing the move being made3.
3. `playedBy` - 0 or 1 for whether p0 or p1 is making the move.

`validate()` should throw if:
* It is not the requesting player's turn (`throw new Error("It's not your turn")`)
* The `currentState`'s `phase !== GamePhase.MAIN`
* The `currentState`'s `oneOff` is not `null` (`throw new Error('There is already a one-off in play; You cannot play any card, except a two to counter.')`)
* The `playedCard` is not in the player's hand (`throw new Error("game.snackbar.global.playFromHand")`)
* The `playedCard` is frozen (`throw new Error('game.snackbar.global.cardFrozen')`)
* The `playedCard`'s rank is not one of the following: 1, 3, 4, 5, 6, 7 (`throw new Error('You cannot play that card as a one-off without a target.')`)
* The `playedCard` has rank === 3 and there are no cards in the scrap (`throw new Error('You can only play a 3 as a one-off, if there are cards in the scrap pile')`)
* The `playedCard` has rank === 4 and their opponent has no cards in their hand `(throw new Error('You cannot play a 4 as a one-off while your opponent has no cards in hand')`)
* The `playedCard` has rank of either 5 or 7 and there are no cards in the deck (`throw new Error('You can't play that card as a one-off, unless there are cards in the deck')`)



### Implementing `execute()`
Create a new `api/helpers/gamestate/moves/untargetedOneOff/execute.js` helper file that matches the general approach of the existing execute helper `api/helpers/gamestate/moves/points/execute.js`. It should create and return a new `GameState` that is the result of making the `untargetedOneOff` move for the requested player i.e. **it should remove `playedCard` from the player's hand and set it as the `oneOff` of the result. It should set the phase to `GamePhase.COUNTERING` WITHOUT INCREMENTING THE TURN. The `playedBy` and `playedCard` attributes should be included in the returned `GameState`.**

`execute()`  should have 3 inputs:
1. `currentState` - the unpacked `GameState` from before the move is made
2. `requestedMove` -req body describing the move being made
3. `playedBy` - 0 or 1 for whether p0 or p1 is making the move.

### Unskipping Tests
Because we are approaching the `GameState API project with a feature-flag approach, all changes are merged into the `main` branch and the application and tests are set to dynamically run in either the GameState API mode, or the legacy mode, based on the `VITE_USE_GAMESTATE_API` env variable. In CI, we run the full suite of tests against the legacy version of the application and then run a subset of the tests against the GameState API version of the application.

Which tests are run in the GameState API version of CI is controlled via the test command `cy.skipOnGameStateApi()` which programmatically skips a test if the env var `VITE_USE_GAMESTATE_API` is enabled. As we develop new features in the GameState API, we should remove the `cy.skipOnGameStateApi()` command from more and more of the tests.

**Essential tests**
Because this does not allow resolving the one-offs, there won't be any tests that we can fully unskip as a result of this change. However, for development purposes, we can unskip `it('Plays an Ace to destroy all point cards')` in `1_aces.spec` and comment out the `assertGameState()` call and everything below it, as well as comment out the last two lines of the `playOneOffAndResolveAsPlayer` command in `commands.js`. Then we should be able to run this spec and see the "Opponent May Counter" scrim.

You can run the tests against the GameState API locally by:
 booting the server in gamestate mode with
`npm run start:server:gamestate`

Then booting the client in gamestate mode with
`npm run start:client:gamestate`

running the tests in gamestate mode with
`npm run e2e:client:gamestate`.

Pull request:
Feat/one off gamestate
<!-- Thanks for contributing to Cuttle! 🎉 -->
* Implements and combines playing targeted and untargeted one-offs into one `MoveType.ONE_OFF`.  Does not enable countering or resolving
* Fixes a bug in `publishGameState` where it incorrectly set the `oneOff` attribute of the game
* Adds `targetType` to `GameState` and `GameStateRow`
## Issue number

Relevant [issue number](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- Resolves #950, #951 

## 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/get-log.js b/api/helpers/gamestate/get-log.js
index 101430e9..81945052 100644
--- a/api/helpers/gamestate/get-log.js
+++ b/api/helpers/gamestate/get-log.js
@@ -59,14 +59,17 @@ module.exports = {
         case MoveType.JACK:
           return `${player} stole ${opponent}'s ${targetCardName} with the ${playedCardName}.`;
 
-        case MoveType.UNTARGETED_ONE_OFF:
-          return `${player} played the ${playedCardName} as a one-off to 
-        ${gameText.moves.effects[playedCard.rank]}.`;
-
-        case MoveType.TARGETED_ONE_OFF:
-          return `${player} played the ${playedCardName} as a one-off to: ${
-            gameText.moves.effects[playedCard.rank]
-          }, targeting the ${targetCardName}.`;
+        case MoveType.ONE_OFF: {
+          const playedCardObj = convertStrToCard(playedCard);
+          let log = `${player} played the ${playedCardName} as a one-off to 
+        ${gameText.moves.effects[playedCardObj.rank]}`;
+          if (targetCardName) {
+            log += `, targeting the ${targetCardName}.`;
+          } else {
+            log += '.';
+          }
+          return log;
+        }
 
         case MoveType.COUNTER:
           if (twos.length > 0) {
diff --git a/api/helpers/gamestate/moves/draw/execute.js b/api/helpers/gamestate/moves/draw/execute.js
index c84860b0..03ce325d 100644
--- a/api/helpers/gamestate/moves/draw/execute.js
+++ b/api/helpers/gamestate/moves/draw/execute.js
@@ -12,7 +12,7 @@ module.exports = {
       required: true,
     },
     /**
-     * @param {Object} requestedMove - Object describing the request to play points (req.body)
+     * @param { Object } requestedMove - Object describing the request to play points (req.body)
      * @param { MoveType.DRAW } requestedMove.moveType - Specifies that this a Draw move
      */
     requestedMove: {
diff --git a/api/helpers/gamestate/moves/one-off/execute.js b/api/helpers/gamestate/moves/one-off/execute.js
new file mode 100644
index 00000000..c91eb7ca
--- /dev/null
+++ b/api/helpers/gamestate/moves/one-off/execute.js
@@ -0,0 +1,83 @@
+const GamePhase = require('../../../../../utils/GamePhase.json');
+
+function findTargetCard(targetId, targetType, opponent) {
+  switch (targetType) {
+    case 'point':
+      return opponent.points.find(({ id }) => id === targetId);
+
+    case 'faceCard':
+      return opponent.faceCards.find(({ id }) => id === targetId);
+
+    case 'jack':
+      for (let point of opponent.points) {
+        for (let jack of point.attachments) {
+          if (jack.id === targetId) {
+            return jack;
+          }
+        }
+      }
+      return;
+
+    default:
+      return null;
+  }
+}
+
+module.exports = {
+  friendlyName: 'Play One-Off',
+
+  description: 'Returns new GameState resulting from requested one-off move',
+
+  inputs: {
+    currentState: {
+      type: 'ref',
+      description: 'The latest GameState before the requesting player plays a one-off',
+      required: true,
+    },
+    /**
+     * @param { Object } requestedMove - The move being requested. 
+     * Specifies that the move is a one-off, which card is being played, 
+     * and if applicable, what target and targetType
+     * @param { MoveType.ONE_OFF } requestedMove.moveType
+     * @param { String } requestedMove.cardId - Card Played for points
+     * @param { String } [ requestedMove.targetId ] - OPTIONAL target of one-off
+     * @param { 'point' | 'faceCard' | 'jack' } [ requestedMove.targetType ] - OPTIONAL where to locate target
+     */
+    requestedMove: {
+      type: 'ref',
+      description: 'The move being requested. Specifies which player is asking to play a one-off ',
+    },
+    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 opponent = playedBy ? result.p0 : result.p1;
+
+    const cardIndex = player.hand.findIndex(({ id }) => id === cardId);
+    const [ playedCard ] = player.hand.splice(cardIndex, 1);
+
+    const targetCard = findTargetCard(requestedMove.targetId, requestedMove.targetType, opponent);
+
+
+    result = {
+      ...result,
+      ...requestedMove,
+      phase: GamePhase.COUNTERING,
+      oneOff: playedCard,
+      playedBy,
+      playedCard,
+      targetCard,
+      oneOffTarget: targetCard,
+      oneOffTargetType: requestedMove.targetType ?? null,
+    };
+
+    return exits.success(result);
+  },
+};
diff --git a/api/helpers/gamestate/moves/one-off/validate.js b/api/helpers/gamestate/moves/one-off/validate.js
new file mode 100644
index 00000000..69782952
--- /dev/null
+++ b/api/helpers/gamestate/moves/one-off/validate.js
@@ -0,0 +1,166 @@
+const GamePhase = require('../../../../../utils/GamePhase.json');
+
+function findTargetCard(targetId, targetType, opponent) {
+  switch (targetType) {
+    case 'point':
+      return opponent.points.find(card => card.id === targetId);
+
+    case 'faceCard':
+      return opponent.faceCards.find(card => card.id === targetId);
+
+    case 'jack':
+      for (let point of opponent.points) {
+        for (let jack of point.attachments) {
+          if (jack.id === targetId) {
+            return jack;
+          }
+        }
+      }
+      return;
+
+    default:
+      throw new Error(`Need a target type to find the ${targetId}`);
+  }
+}
+
+module.exports = {
+  friendlyName: 'Validate request to play one-off',
+
+  description:
+    'Verifies whether a request to play one-off 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
+     * @param { MoveType.ONE_OFF } requestedMove.moveType
+     * @param { String } requestedMove.cardId - Card Played for points
+     * @param { String } [ requestedMove.targetId ] - OPTIONAL target of one-off used for 2's and 9's
+     * @param { 'point' | 'faceCard' | 'jack' } [ requestedMove.targetType ] - OPTIONAL where one-off target is located
+     */
+    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 player = playedBy ? currentState.p1 : currentState.p0;
+      const opponent = playedBy ? currentState.p0 : currentState.p1;
+
+      const cardPlayed = player.hand.find(({ id }) => id === requestedMove.cardId);
+
+      if (currentState.phase !== GamePhase.MAIN) {
+        throw new Error(`Can only play one-off in main phase, not ${currentState.phase}`);
+      }
+
+      if (currentState.oneOff) {
+        throw new Error(
+          'There is already a one-off in play; You cannot play any card, except a two to counter.',
+        );
+      }
+
+      if (!cardPlayed) {
+        throw new Error('game.snackbar.global.playFromHand');
+      }
+
+      if (currentState.turn % 2 !== playedBy) {
+        throw new Error('game.snackbar.global.notYourTurn');
+      }
+
+      if (cardPlayed.isFrozen) {
+        throw new Error('game.snackbar.global.cardFrozen');
+      }
+
+      switch (cardPlayed.rank) {
+        // Ace and Six are always allowed
+        case 1:
+        case 6:
+          return exits.success();
+
+        // 2 and 9 require legal target
+        case 2:
+        case 9:
+          {
+            const targetCard = findTargetCard(requestedMove.targetId, requestedMove.targetType, opponent);
+            // Must have target
+            if (!targetCard) {
+              throw new Error(`Can't find the ${requestedMove.targetId} on opponent's board`);
+            }
+
+            if (cardPlayed.rank === 2 && !['faceCard', 'jack'].includes(requestedMove.targetType)) {
+              throw new Error('Twos can only target royals or glasses');
+            }
+
+            const queenCount = opponent.faceCards.filter(
+              (faceCard) => faceCard.rank === 12
+            ).length;
+
+            // Legal if not blocked by opponent's queen(s)
+            switch (queenCount) {
+              // No queens => always allowed
+              case 0:
+                return exits.success();
+
+              // One queen => can only target the queen
+              case 1: {
+                if (targetCard.rank !== 12) {
+                  throw new Error('game.snackbar.global.blockedByQueen');
+                }
+                return exits.success();
+              }
+
+              // 2+ queens => Can't target at all
+              default:
+                throw new Error('game.snackbar.global.blockedByMultipleQueens');
+            }
+          }
+
+        // Three requires card(s) in scrap
+        case 3:
+          if (!currentState.scrap.length) {
+            throw new Error('game.snackbar.oneOffs.scrapIsEmpty');
+          }
+          return exits.success();
+
+        // Four requires opponent to have cards in hand
+        case 4:
+          if (!opponent.hand.length) {
+            throw new Error('game.snackbar.oneOffs.opponentHasNoCards');
+          }
+          return exits.success();
+
+        // Five requires cards in deck
+        case 5:
+          if (!currentState.deck.length) {
+            throw new Error('game.snackbar.five.fiveDeckIsEmpty');
+          }
+          return exits.success();
+
+        // Seven requires cards in deck
+        case 7:
+          if (!currentState.deck.length) {
+            throw new Error('game.snackbar.oneOffs.sevenWithEmptyDeck');
+          }
+          return exits.success();
+
+        // No other cards can be used for a one-off
+        default:
+          throw new Error('You cannot play that card as a one-off without a target.');
+      }
+    } catch (err) {
+      return exits.error(err);
+    }
+  },
+};
diff --git a/api/helpers/gamestate/moves/points/execute.js b/api/helpers/gamestate/moves/points/execute.js
index 77d63edd..6c16a2ef 100644
--- a/api/helpers/gamestate/moves/points/execute.js
+++ b/api/helpers/gamestate/moves/points/execute.js
@@ -12,8 +12,8 @@ module.exports = {
       required: true,
     },
     /**
-     * @param {Object} requestedMove - Object describing the request to play points (req.body)
-     * @param { Card } requestedMove.cardPlayed - Card Played for points
+     * @param { Object } requestedMove - Object describing the request to play points (req.body)
+     * @param { String } requestedMove.cardId - Card Played for points
      * @param { MoveType.POINTS } requestedMove.moveType - Specifies that this a Points move
      */
     requestedMove: {
diff --git a/api/helpers/gamestate/moves/points/validate.js b/api/helpers/gamestate/moves/points/validate.js
index eabde5f8..d0996805 100644
--- a/api/helpers/gamestate/moves/points/validate.js
+++ b/api/helpers/gamestate/moves/points/validate.js
@@ -12,9 +12,8 @@ module.exports = {
       required: true,
     },
     /**
-     * @param {Object} requestedMove - Object describing the request to play points
-     * @param {1 | 0} requestedMove.playedBy - Which player is playing
-     * @param { Card } requestedMove.cardPlayed - Card Played for points
+     * @param { Object } requestedMove - Object describing the request to play points
+     * @param { String } requestedMove.cardId - Card Played for points
      * @param { MoveType.POINTS } requestedMove.moveType - Specifies that this a Points move
      */
     requestedMove: {
diff --git a/api/helpers/gamestate/publish-game-state.js b/api/helpers/gamestate/publish-game-state.js
index 533a439f..aae688db 100644
--- a/api/helpers/gamestate/publish-game-state.js
+++ b/api/helpers/gamestate/publish-game-state.js
@@ -27,19 +27,6 @@ module.exports = {
       delete p1.encryptedPassword;
       const players = [p0, p1];
 
-      const lastEventTargetType = () => {
-        if (!['targetedOneOff', 'sevenTargetedOneOff'].includes(gameState.moveType)) {
-          return '';
-        }
-        if (gameState.targetCard?.rank === 11) {
-          return 'jack';
-        }
-        if (gameState.targetCard?.rank > 11) {
-          return 'faceCard';
-        }
-        return 'point';
-      };
-
       const victory = await sails.helpers.gamestate.checkGameStateForWin(game, gameState);
 
       const countPasses = () => {
@@ -92,15 +79,17 @@ module.exports = {
         secondCard: gameState.deck[1],
         twos: gameState.twos,
         resolved: gameState.resolved,
-        oneOffTargetType: lastEventTargetType(),
+        oneOff: gameState.oneOff,
+        oneOffTarget: gameState.oneOffTarget,
+        oneOffTargetType: gameState.oneOffTargetType,
         lastEvent: {
           change: gameState.moveType,
-          oneOffTargetType: lastEventTargetType(),
-          chosenCard,
           pNum,
           happened,
-          discardedCards,
-          oneOff: gameState.resolved,
+          // Conditionally included properties if truthy
+          ...(gameState.resolved && {oneOff: gameState.resolved}),
+          ...(chosenCard && { chosenCard }),
+          ...(discardedCards && { discardedCards }),
         },
       };
 
@@ -109,11 +98,12 @@ module.exports = {
         game: socketGame,
         victory,
         happened,
-        discardedCards,
-        chosenCard,
         playedBy,
         pNum,
-        oneOff: gameState.resolved,
+        // Conditionally included properties if truthy
+        ...(gameState.resolved && {oneOff: gameState.resolved}),
+        ...(chosenCard && { chosenCard }),
+        ...(discardedCards && { discardedCards }),
       };
 
       Game.publish([game.id], fullSocketEvent);
diff --git a/api/helpers/gamestate/unpack-gamestate.js b/api/helpers/gamestate/unpack-gamestate.js
index f08fd419..58d5e3c3 100644
--- a/api/helpers/gamestate/unpack-gamestate.js
+++ b/api/helpers/gamestate/unpack-gamestate.js
@@ -81,6 +81,7 @@ module.exports = {
         targetCard: convertedData.targetCard ?? null,
         oneOff: convertedData.oneOff ?? null,
         oneOffTarget: convertedData.oneOffTarget ?? null,
+        oneOffTargetType: gameStateRow.oneOffTargetType,
         resolved: convertedData.resolved ?? null,
         discardedCards: convertedData.discardedCards ?? null,
       };
diff --git a/api/helpers/gamestate/validate-gamestate.js b/api/helpers/gamestate/validate-gamestate.js
index 1442f101..188eb1f9 100644
--- a/api/helpers/gamestate/validate-gamestate.js
+++ b/api/helpers/gamestate/validate-gamestate.js
@@ -84,6 +84,7 @@ module.exports = {
         targetCard: gameState.targetCard ?? null,
         oneOff: gameState.oneOff ?? null,
         oneOffTarget: gameState.oneOffTarget ?? null,
+        oneOffTargetType: gameState.oneOffTargetType ?? null,
         resolved: gameState.resolved ?? null,
       };
 
diff --git a/api/models/GameStateRow.js b/api/models/GameStateRow.js
index 14c7f98e..feb5b0ca 100644
--- a/api/models/GameStateRow.js
+++ b/api/models/GameStateRow.js
@@ -52,7 +52,7 @@ module.exports = {
     discardedCards: {
       type: 'json',
       defaultsTo: [],
-      columnType: 'text[]',
+      columnType: 'jsonb',
     },
     // Which turn number the move was made on
     turn: {
@@ -77,49 +77,49 @@ module.exports = {
     // Cards in p0’s hand
     p0Hand: {
       type: 'json',
-      columnType: 'text[]',
+      columnType: 'jsonb',
       required: true,
     },
     // Cards in p1’s hand
     p1Hand: {
       type: 'json',
-      columnType: 'text[]',
+      columnType: 'jsonb',
       required: true,
     },
     // Cards in p0’s points
     p0Points: {
       type: 'json',
-      columnType: 'text[]',
+      columnType: 'jsonb',
       required: true,
     },
     // Cards in p1’s points
     p1Points: {
       type: 'json',
-      columnType: 'text[]',
+      columnType: 'jsonb',
       required: true,
     },
     // Cards in p0’s face cards
     p0FaceCards: {
       type: 'json',
-      columnType: 'text[]',
+      columnType: 'jsonb',
       required: true,
     },
     // Cards in p1’s face cards
     p1FaceCards: {
       type: 'json',
-      columnType: 'text[]',
+      columnType: 'jsonb',
       required: true,
     },
     // Cards in the deck, in order
     deck: {
       type: 'json',
-      columnType: 'text[]',
+      columnType: 'jsonb',
       required: true,
     },
     // Cards in the scrap
     scrap: {
       type: 'json',
-      columnType: 'text[]',
+      columnType: 'jsonb',
       required: true,
     },
     // One-off card
@@ -132,10 +132,15 @@ module.exports = {
       type: 'string',
       allowNull: true,
     },
+    oneOffTargetType: {
+      type: 'string',
+      allowNull: true,
+      isIn: ['point', 'jack', 'faceCard'],
+    },
     // Twos
     twos: {
       type: 'json',
-      columnType: 'text[]',
+      columnType: 'jsonb',
       required: true,
     },
     // Resolved card
diff --git a/api/policies/hasValidMoveBody.js b/api/policies/hasValidMoveBody.js
index c08ad2e4..fa8eee0c 100644
--- a/api/policies/hasValidMoveBody.js
+++ b/api/policies/hasValidMoveBody.js
@@ -10,18 +10,30 @@ const DeckIds = require('../../utils/DeckIds.json');
  *
  */
 module.exports = function (req, res, next) {
-  switch (req.body.moveType) {
+  const { moveType, cardId, targetId, targetType } = req.body;
+
+  switch (moveType) {
     case MoveType.DRAW:
       // Draw requires no extra data
       return next();
+
     case MoveType.POINTS:
+    case MoveType.ONE_OFF: {
+      if (!cardId) {
+        return res.badRequest({ message: 'Must specify a card' });
+      }
 
-      if (!req.body.cardId) {
-        return res.badRequest({ message: 'Cannot play points without specifying a card' });
+      if (!DeckIds.includes(cardId)) {
+        return res.badRequest({ message: `${cardId} is not a valid cardId` });
       }
-      if (!DeckIds.includes(req.body.cardId)) {
-        return res.badRequest({ message: `${req.body.cardId} is not a valid cardId` });
+
+      const [ rankAsStr ] = cardId;
+      const isTwoOrNine = ['2', '9'].includes(rankAsStr);
+      const missingTarget = !targetId || !DeckIds.includes(targetId) || !['point', 'jack', 'faceCard'].includes(targetType);
+      if (isTwoOrNine && missingTarget) {
+        return res.badRequest({ message: 'You cannot play that one-off without a target' });
       }
+    }
 
       return next();
     default:
diff --git a/src/stores/game.js b/src/stores/game.js
index a149ba18..77be8901 100644
--- a/src/stores/game.js
+++ b/src/stores/game.js
@@ -535,7 +535,7 @@ export const useGameStore = defineStore('game', {
     },
 
     async requestPlayOneOff(cardId) {
-      const moveType = MoveType.UNTARGETED_ONE_OFF;
+      const moveType = MoveType.ONE_OFF;
       await this.makeSocketRequest('untargetedOneOff', {
         moveType,
         cardId,
@@ -546,7 +546,7 @@ export const useGameStore = defineStore('game', {
     },
 
     async requestPlayTargetedOneOff({ cardId, targetId, pointId, targetType }) {
-      const moveType = MoveType.TARGETED_ONE_OFF;
+      const moveType = MoveType.ONE_OFF;
       await this.makeSocketRequest('targetedOneOff', {
         moveType,
         cardId,
@@ -672,7 +672,7 @@ export const useGameStore = defineStore('game', {
 
     async requestPlayOneOffSeven({ cardId, index }) {
       await this.makeSocketRequest('seven/untargetedOneOff', {
-        moveType: MoveType.SEVEN_UNTARGETED_ONE_OFF,
+        moveType: MoveType.SEVEN_ONE_OFF,
         cardId,
         index, // 0 if topCard, 1 if secondCard
         opId: this.opponent.id,
@@ -682,7 +682,7 @@ export const useGameStore = defineStore('game', {
 
     async requestPlayTargetedOneOffSeven({ cardId, index, targetId, pointId, targetType }) {
       await this.makeSocketRequest('seven/targetedOneOff', {
-        moveType: MoveType.SEVEN_TARGETED_ONE_OFF,
+        moveType: MoveType.SEVEN_ONE_OFF,
         cardId,
         targetId,
         pointId,
diff --git a/utils/MoveType.json b/utils/MoveType.json
index 08d76044..5cb97239 100644
--- a/utils/MoveType.json
+++ b/utils/MoveType.json
@@ -5,8 +5,7 @@
   "SCUTTLE": "scuttle",
   "FACECARD": "faceCard",
   "JACK": "jack",
-  "UNTARGETED_ONE_OFF": "untargetedOneOff",
-  "TARGETED_ONE_OFF": "targetedOneOff",
+  "ONE_OFF": "oneOff",
   "COUNTER": "counter",
   "RESOLVE": "resolve",
   "FIZZLE": "fizzle",
@@ -17,8 +16,7 @@
   "SEVEN_SCUTTLE": "sevenScuttle",
   "SEVEN_FACECARD": "sevenFaceCard",
   "SEVEN_JACK": "sevenJack",
-  "SEVEN_UNTARGETED_ONE_OFF": "sevenUntargetedOneOff",
-  "SEVEN_TARGETED_ONE_OFF": "sevenTargetedOneOff",
+  "SEVEN_ONE_OFF": "sevenOneOff",
   "PASS": "pass",
   "LOADFIXTURE": "loadFixture"
 }


Test patch:
diff --git a/tests/e2e/support/commands.js b/tests/e2e/support/commands.js
index cdfef6b7..3935818f 100644
--- a/tests/e2e/support/commands.js
+++ b/tests/e2e/support/commands.js
@@ -358,7 +358,7 @@ Cypress.Commands.add('playOneOffSpectator', (card, pNum) => {
         );
       }
 
-      const moveType = MoveType.UNTARGETED_ONE_OFF;
+      const moveType = MoveType.ONE_OFF;
       const cardId = foundCard.id;
       const opId = game.players[(pNum + 1) % 2].id;
       cy.makeSocketRequest('game', 'untargetedOneOff', { moveType, cardId, opId });
@@ -487,7 +487,7 @@ Cypress.Commands.add('playOneOffOpponent', (card) => {
         );
       }
 
-      const moveType = MoveType.UNTARGETED_ONE_OFF;
+      const moveType = MoveType.ONE_OFF;
       cy.makeSocketRequest('game', 'untargetedOneOff', {
         moveType,
         opId: playerId,
@@ -558,7 +558,7 @@ Cypress.Commands.add('playTargetedOneOffOpponent', (card, target, targetType) =>
         );
       }
 
-      const moveType = MoveType.TARGETED_ONE_OFF;
+      const moveType = MoveType.ONE_OFF;
       cy.makeSocketRequest('game', 'targetedOneOff', {
         moveType,
         opId: playerId, // opponent's opponent is the player
diff --git a/tests/unit/fixtures/gameStates/points.js b/tests/unit/fixtures/gameStates/points.js
index c239cc03..d4b1cada 100644
--- a/tests/unit/fixtures/gameStates/points.js
+++ b/tests/unit/fixtures/gameStates/points.js
@@ -18,6 +18,7 @@ const gameStateRow = {
   playedCard: 'TS',
   oneOff: null,
   oneOffTarget: null,
+  oneOffTargetType: null,
   resolved: null,
   gameId: 1,
   playedBy: 0,
@@ -51,6 +52,7 @@ export const points = {
     discardedCards: [],
     oneOff: null,
     oneOffTarget: null,
+    oneOffTargetType: null,
     resolved: null,
     playedCard: Card.TEN_OF_SPADES,
     gameId: 1,
@@ -72,11 +74,8 @@ export const points = {
   socket: {
     change: MoveType.POINTS,
     happened: true,
-    oneOff: null,
-    discardedCards: null,
     playedBy: 0,
     pNum: 0,
-    chosenCard: null,
     game: {
       players: [
         {
@@ -165,15 +164,13 @@ export const points = {
       secondCard: Card.SEVEN_OF_CLUBS,
       twos: [],
       resolved: null,
-      oneOffTargetType: '',
+      oneOff: null,
+      oneOffTarget: null,
+      oneOffTargetType: null,
       lastEvent: {
         change: MoveType.POINTS,
-        oneOff: null,
-        oneOffTargetType: '',
-        chosenCard: null,
         happened: true,
         pNum: 0,
-        discardedCards: null,
       },
     },
     victory: {
diff --git a/tests/unit/fixtures/gameStates/resolveNine.js b/tests/unit/fixtures/gameStates/resolveNine.js
index d50047c5..d046e2d2 100644
--- a/tests/unit/fixtures/gameStates/resolveNine.js
+++ b/tests/unit/fixtures/gameStates/resolveNine.js
@@ -21,6 +21,7 @@ const gameStateRow = {
   scrap: ['9C'],
   oneOff: null,
   oneOffTarget: null,
+  oneOffTargetType: null,
   twos: [],
   resolved: '9C',
   gameId: 1,
@@ -53,6 +54,7 @@ export const resolveNine = {
     scrap: [Card.NINE_OF_CLUBS],
     oneOff: null,
     oneOffTarget: null,
+    oneOffTargetType: null,
     twos: [],
     resolved: Card.NINE_OF_CLUBS,
     gameId: 1,
@@ -62,10 +64,8 @@ export const resolveNine = {
     change: MoveType.RESOLVE,
     happened: true,
     oneOff: Card.NINE_OF_CLUBS,
-    discardedCards: null,
     pNum: 0,
     playedBy: 0,
-    chosenCard: null,
     game: {
       chat: [],
       deck: [],
@@ -73,10 +73,7 @@ export const resolveNine = {
       lastEvent: {
         change: MoveType.RESOLVE,
         happened: true,
-        chosenCard: null,
         oneOff: Card.NINE_OF_CLUBS,
-        oneOffTargetType: '',
-        discardedCards: null,
         pNum: 0,
       },
       lock: null,
@@ -86,7 +83,9 @@ export const resolveNine = {
       ],
       match: null,
       name: 'Test Game',
-      oneOffTargetType: '',
+      oneOff: null,
+      oneOffTarget: null,
+      oneOffTargetType: null,
       p0Ready: true,
       p0Rematch: null,
       p1Ready: true,
diff --git a/tests/unit/fixtures/gameStates/resolveThree.js b/tests/unit/fixtures/gameStates/resolveThree.js
index a6feba1a..8a02d921 100644
--- a/tests/unit/fixtures/gameStates/resolveThree.js
+++ b/tests/unit/fixtures/gameStates/resolveThree.js
@@ -22,6 +22,7 @@ const gameStateRow = {
   scrap: [],
   oneOff: null,
   oneOffTarget: null,
+  oneOffTargetType: null,
   twos: [],
   resolved: '3S',
   gameId: 1,
@@ -50,6 +51,7 @@ export const resolveThree = {
     scrap: [],
     oneOff: null,
     oneOffTarget: null,
+    oneOffTargetType: null,
     twos: [],
     resolved: Card.THREE_OF_SPADES,
     gameId: 1,
@@ -66,7 +68,6 @@ export const resolveThree = {
     change: MoveType.RESOLVE_THREE,
     happened: true,
     oneOff: Card.THREE_OF_SPADES,
-    discardedCards: null,
     chosenCard: Card.TEN_OF_HEARTS,
     playedBy: 0,
     pNum: 0,
@@ -112,7 +113,9 @@ export const resolveThree = {
       rematchGame: null,
       resolved: Card.THREE_OF_SPADES,
       passes: 0,
-      oneOffTargetType: '',
+      oneOff: null,
+      oneOffTarget: null,
+      oneOffTargetType: null,
       turn: 1,
       log: ['myUsername took the 10♥️ from the Scrap pile to their hand.'],
       turnStalemateWasRequestedByP0: -1,
@@ -124,8 +127,6 @@ export const resolveThree = {
         pNum: 0,
         chosenCard: Card.TEN_OF_HEARTS,
         oneOff: Card.THREE_OF_SPADES,
-        oneOffTargetType: '',
-        discardedCards: null,
         happened: true,
       },
       lock: null,