From f6cf4da835a5a56ce0b6329c49e02fe488d69cdb Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Sat, 2 May 2026 12:26:51 -0400 Subject: [PATCH 01/10] feat: replace eager hand-limit enforcement with discard-to-hand-limit phase Introduces a new DISCARDING_TO_HAND_LIMIT game phase (value 6) so players can always draw or resolve moves that overflow the 8-card hand limit, then discard down immediately rather than being blocked upfront. - Draw: removed the block-at-8 validation; drawing a 9th card enters the new phase instead of incrementing turn - Five: always draws 3 cards (removed the spaceInHand cap); overflows trigger the discard phase - Nine: if the returned card pushes the targeted player over 8, they enter the discard phase before getting their turn - New discard-to-hand-limit validate/execute helpers; supports discarding 1 card (hand=9) or 2 cards (hand=10) in a single move - Frontend: DiscardToHandLimitDialog, game store computed/action wiring, socket event handler, and i18n strings - AI: generates all valid discard combinations for the new move type - Tests: updated basicMoves, 5_fives, and 9_nines specs; new handLimit.spec.js Co-Authored-By: Claude Sonnet 4.6 --- .../ai/get-move-bodies-for-move-type.js | 18 ++ .../game-states/get-active-player-p-num.js | 3 + api/helpers/game-states/get-log.js | 5 + .../moves/discard-to-hand-limit/execute.js | 70 ++++++ .../moves/discard-to-hand-limit/validate.js | 66 +++++ api/helpers/game-states/moves/draw/execute.js | 8 +- .../game-states/moves/draw/validate.js | 6 - .../game-states/moves/resolve-five/execute.js | 10 +- .../game-states/moves/resolve/execute.js | 8 +- src/plugins/sockets/inGameEvents.js | 3 + .../game/components/dialogs/GameDialogs.vue | 14 ++ .../components/DiscardToHandLimitDialog.vue | 118 +++++++++ src/stores/game.js | 12 + src/translations/en.json | 10 + tests/e2e/specs/in-game/basicMoves.spec.js | 67 +++++- tests/e2e/specs/in-game/handLimit.spec.js | 226 ++++++++++++++++++ .../specs/in-game/one-offs/5_fives.spec.js | 47 ++-- .../specs/in-game/one-offs/9_nines.spec.js | 93 +++++++ tests/e2e/support/commands.js | 16 ++ types/SocketEvent.js | 1 + utils/GamePhase.json | 1 + utils/MoveType.json | 1 + 22 files changed, 749 insertions(+), 54 deletions(-) create mode 100644 api/helpers/game-states/moves/discard-to-hand-limit/execute.js create mode 100644 api/helpers/game-states/moves/discard-to-hand-limit/validate.js create mode 100644 src/routes/game/components/dialogs/components/DiscardToHandLimitDialog.vue create mode 100644 tests/e2e/specs/in-game/handLimit.spec.js diff --git a/api/helpers/game-states/ai/get-move-bodies-for-move-type.js b/api/helpers/game-states/ai/get-move-bodies-for-move-type.js index 1a35c3b7c..5e2f2127a 100644 --- a/api/helpers/game-states/ai/get-move-bodies-for-move-type.js +++ b/api/helpers/game-states/ai/get-move-bodies-for-move-type.js @@ -140,6 +140,24 @@ module.exports = { } break; + case MoveType.DISCARD_TO_HAND_LIMIT: { + if (playerHand.length === 9) { + res = playerHand.map((card) => ({ moveType, playedBy, cardId1: card.id })); + } else { + for (let i = 0; i < playerHand.length; i++) { + for (let j = i + 1; j < playerHand.length; j++) { + res.push({ + moveType, + playedBy, + cardId1: playerHand[i].id, + cardId2: playerHand[j].id, + }); + } + } + } + break; + } + case MoveType.SEVEN_POINTS: res = deck.slice(0, 2) .filter((card) => card.rank <= 10) diff --git a/api/helpers/game-states/get-active-player-p-num.js b/api/helpers/game-states/get-active-player-p-num.js index f0723879f..c1dc17068 100644 --- a/api/helpers/game-states/get-active-player-p-num.js +++ b/api/helpers/game-states/get-active-player-p-num.js @@ -31,6 +31,9 @@ module.exports = { case GamePhase.RESOLVING_FOUR: return exits.success((currentState.turn + 1) % 2); + case GamePhase.DISCARDING_TO_HAND_LIMIT: + return exits.success(currentState.p0.hand.length > 8 ? 0 : 1); + case GamePhase.CONSIDERING_STALEMATE: return exits.success((currentState.playedBy + 1) % 2); diff --git a/api/helpers/game-states/get-log.js b/api/helpers/game-states/get-log.js index 7284f93a8..739a583ee 100644 --- a/api/helpers/game-states/get-log.js +++ b/api/helpers/game-states/get-log.js @@ -120,6 +120,11 @@ module.exports = { discardedCards.length > 1 ? `and the ${getFullCardName(discardedCards[1])}.` : '.' }`; + case MoveType.DISCARD_TO_HAND_LIMIT: + return `${player} discarded the ${getFullCardName(discardedCards[0])} ${ + discardedCards.length > 1 ? `and the ${getFullCardName(discardedCards[1])} ` : '' + }to the hand limit.`; + case MoveType.RESOLVE_FIVE: if (discardedCards.length) { return `${player} discarded the ${getFullCardName( diff --git a/api/helpers/game-states/moves/discard-to-hand-limit/execute.js b/api/helpers/game-states/moves/discard-to-hand-limit/execute.js new file mode 100644 index 000000000..8eebeb157 --- /dev/null +++ b/api/helpers/game-states/moves/discard-to-hand-limit/execute.js @@ -0,0 +1,70 @@ +const GamePhase = require('../../../../../utils/GamePhase.json'); + +module.exports = { + friendlyName: 'Discard to hand limit', + + description: 'Returns new GameState resulting from a player discarding excess cards to reach the hand limit', + + inputs: { + currentState: { + type: 'ref', + description: 'The latest GameState before the requesting player discards excess cards', + required: true, + }, + /** + * @param { Object } requestedMove - Object describing the request to discard to hand limit + * @param { String } requestedMove.cardId1 - First card to be discarded + * @param { String } [requestedMove.cardId2] - Second card to be discarded (if needed) + * @param { MoveType.DISCARD_TO_HAND_LIMIT } requestedMove.moveType + */ + requestedMove: { + type: 'ref', + description: 'The move being requested.', + }, + playedBy: { + type: 'number', + description: 'Player number of player requesting move.', + }, + priorStates: { + type: 'ref', + description: 'List of packed gameStateRows for this game\'s prior states', + required: true, + } + }, + sync: true, + fn: ({ currentState, requestedMove, playedBy }, exits) => { + const { cardId1, cardId2 } = requestedMove; + let result = _.cloneDeep(currentState); + + const player = playedBy ? result.p1 : result.p0; + const discardedCards = []; + + const cardIndex1 = player.hand.findIndex(({ id }) => id === cardId1); + discardedCards.push(player.hand[cardIndex1]); + result.scrap.push(...player.hand.splice(cardIndex1, 1)); + + if (cardId2) { + const cardIndex2 = player.hand.findIndex(({ id }) => id === cardId2); + if (cardIndex2 !== -1) { + discardedCards.push(player.hand[cardIndex2]); + result.scrap.push(...player.hand.splice(cardIndex2, 1)); + } + } + + result.turn++; + + result = { + ...result, + ...requestedMove, + phase: GamePhase.MAIN, + playedBy, + playedCard: null, + targetCard: null, + discardedCards, + resolved: null, + oneOff: null, + }; + + return exits.success(result); + }, +}; diff --git a/api/helpers/game-states/moves/discard-to-hand-limit/validate.js b/api/helpers/game-states/moves/discard-to-hand-limit/validate.js new file mode 100644 index 000000000..f2ee08980 --- /dev/null +++ b/api/helpers/game-states/moves/discard-to-hand-limit/validate.js @@ -0,0 +1,66 @@ +const GamePhase = require('../../../../../utils/GamePhase.json'); +const BadRequestError = require('../../../../errors/badRequestError'); + +module.exports = { + friendlyName: 'Validate request to discard to hand limit', + + description: 'Verifies whether a request to discard excess cards 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 discard to hand limit + * @param { String } requestedMove.cardId1 - First card to be discarded + * @param { String } [requestedMove.cardId2] - Second card to be discarded (required if hand > 9) + * @param { MoveType.DISCARD_TO_HAND_LIMIT } requestedMove.moveType + */ + 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, + }, + priorStates: { + type: 'ref', + description: 'List of packed gameStateRows for this game\'s prior states', + required: true, + } + }, + sync: true, + fn: ({ requestedMove, currentState, playedBy }, exits) => { + try { + if (currentState.phase !== GamePhase.DISCARDING_TO_HAND_LIMIT) { + throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.notDiscardingPhase'); + } + + const player = playedBy ? currentState.p1 : currentState.p0; + + if (player.hand.length <= 8) { + throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.handNotOverLimit'); + } + + const { cardId1, cardId2 } = requestedMove; + + const selectedCard1 = player.hand.find(card => card.id === cardId1); + if (!selectedCard1) { + throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.mustSelectCards'); + } + + if (player.hand.length > 9 && !player.hand.find(card => card.id === cardId2)) { + throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.mustSelectCards'); + } + + return exits.success(); + } catch (err) { + return exits.error(err); + } + }, +}; diff --git a/api/helpers/game-states/moves/draw/execute.js b/api/helpers/game-states/moves/draw/execute.js index b6a64e27b..1d8c4ae45 100644 --- a/api/helpers/game-states/moves/draw/execute.js +++ b/api/helpers/game-states/moves/draw/execute.js @@ -36,12 +36,16 @@ module.exports = { const player = playedBy ? result.p1 : result.p0; player.hand.push(result.deck.shift()); - result.turn++; + const handExceedsLimit = player.hand.length > 8; + + if (!handExceedsLimit) { + result.turn++; + } result = { ...result, ...requestedMove, - phase: GamePhase.MAIN, + phase: handExceedsLimit ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN, playedBy, playedCard: null, targetCard: null, diff --git a/api/helpers/game-states/moves/draw/validate.js b/api/helpers/game-states/moves/draw/validate.js index e133c0c7c..2510b8765 100644 --- a/api/helpers/game-states/moves/draw/validate.js +++ b/api/helpers/game-states/moves/draw/validate.js @@ -47,12 +47,6 @@ module.exports = { throw new BadRequestError('game.snackbar.global.notYourTurn'); } - // Must be under hand limit of 8 - const player = playedBy ? currentState.p1 : currentState.p0; - if (player.hand.length >= 8) { - throw new BadRequestError('game.snackbar.draw.handLimit'); - } - // Deck must have cards if (!currentState.deck.length) { throw new BadRequestError('game.snackbar.draw.deckIsEmpty'); diff --git a/api/helpers/game-states/moves/resolve-five/execute.js b/api/helpers/game-states/moves/resolve-five/execute.js index 4fa319549..8ac567d05 100644 --- a/api/helpers/game-states/moves/resolve-five/execute.js +++ b/api/helpers/game-states/moves/resolve-five/execute.js @@ -48,21 +48,19 @@ module.exports = { } const cardsToDraw = Math.min(3, result.deck.length); - const spaceInHand = 8 - player.hand.length; - const actualCardsToDraw = Math.min(cardsToDraw, spaceInHand); - - player.hand.push(...result.deck.splice(0, actualCardsToDraw)); + player.hand.push(...result.deck.splice(0, cardsToDraw)); + const handExceedsLimit = player.hand.length > 8; result = { ...result, ...requestedMove, - phase: GamePhase.MAIN, + phase: handExceedsLimit ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN, playedBy, playedCard: null, targetCard: null, resolved: result.oneOff, oneOff: null, - turn: result.turn + 1, + turn: handExceedsLimit ? result.turn : result.turn + 1, }; return exits.success(result); diff --git a/api/helpers/game-states/moves/resolve/execute.js b/api/helpers/game-states/moves/resolve/execute.js index 834b936ef..1f4c61267 100644 --- a/api/helpers/game-states/moves/resolve/execute.js +++ b/api/helpers/game-states/moves/resolve/execute.js @@ -89,7 +89,10 @@ module.exports = { default: return exits.error(new Error(`${oneOff.rank} is not a valid one-off rank`)); } - + + const nineTargetedPlayer = playedBy ? result.p1 : result.p0; + const nineOverflow = oneOff.rank === 9 && nineTargetedPlayer.hand.length > 8; + result.scrap.push(result.oneOff); result = { ...result, @@ -97,7 +100,8 @@ module.exports = { targetCard: result.oneOffTarget, oneOffTarget: null, oneOffTargetType: null, - turn: result.turn + 1, + phase: nineOverflow ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN, + turn: nineOverflow ? result.turn : result.turn + 1, }; return exits.success(result); diff --git a/src/plugins/sockets/inGameEvents.js b/src/plugins/sockets/inGameEvents.js index 879b6382e..7e56e4148 100644 --- a/src/plugins/sockets/inGameEvents.js +++ b/src/plugins/sockets/inGameEvents.js @@ -88,6 +88,9 @@ export async function handleInGameEvents(evData, newRoute = null) { gameStore.updateGame(evData.game); } break; + case SocketEvent.DISCARD_TO_HAND_LIMIT: + gameStore.updateGame(evData.game); + break; case SocketEvent.REMATCH: gameStore.setRematch({ pNum: evData.pNum, rematch: evData.game[`p${evData.pNum}Rematch`] }); diff --git a/src/routes/game/components/dialogs/GameDialogs.vue b/src/routes/game/components/dialogs/GameDialogs.vue index 47662722d..896551937 100644 --- a/src/routes/game/components/dialogs/GameDialogs.vue +++ b/src/routes/game/components/dialogs/GameDialogs.vue @@ -21,6 +21,10 @@ + 1 ? cardIds[1] : null; + this.gameStore.requestDiscardToHandLimit({ + cardId1, + cardId2, + }); + }, handleError() { this.$emit('handle-error'); }, diff --git a/src/routes/game/components/dialogs/components/DiscardToHandLimitDialog.vue b/src/routes/game/components/dialogs/components/DiscardToHandLimitDialog.vue new file mode 100644 index 000000000..72c3ca0ea --- /dev/null +++ b/src/routes/game/components/dialogs/components/DiscardToHandLimitDialog.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/src/stores/game.js b/src/stores/game.js index 939445a74..7321b6faf 100644 --- a/src/stores/game.js +++ b/src/stores/game.js @@ -219,11 +219,16 @@ export const useGameStore = defineStore('game', () => { return isPlayersTurn.value; case GamePhase.RESOLVING_FIVE: return !isPlayersTurn.value; + case GamePhase.DISCARDING_TO_HAND_LIMIT: + return (player.value?.hand?.length ?? 0) <= 8; default: return false; } }); const showResolveFive = computed(() => phase.value === GamePhase.RESOLVING_FIVE && isPlayersTurn.value); + const showDiscardToHandLimit = computed( + () => phase.value === GamePhase.DISCARDING_TO_HAND_LIMIT && (player.value?.hand?.length ?? 0) > 8, + ); const playingFromDeck = computed(() => phase.value === GamePhase.RESOLVING_SEVEN && isPlayersTurn.value); const waitingForOpponentToPlayFromDeck = computed( () => phase.value === GamePhase.RESOLVING_SEVEN && !isPlayersTurn.value, @@ -624,6 +629,11 @@ export const useGameStore = defineStore('game', () => { const reqData = cardId2 ? { moveType, cardId1, cardId2 } : { moveType, cardId1 }; await makeSocketRequest('resolveFour', reqData); } + async function requestDiscardToHandLimit({ cardId1, cardId2 }) { + const moveType = MoveType.DISCARD_TO_HAND_LIMIT; + const reqData = cardId2 ? { moveType, cardId1, cardId2 } : { moveType, cardId1 }; + await makeSocketRequest('discardToHandLimit', reqData); + } async function requestResolve() { const moveType = MoveType.RESOLVE; await makeSocketRequest('resolve', { moveType }); @@ -763,6 +773,7 @@ export const useGameStore = defineStore('game', () => { showResolveFour, waitingForOpponentToDiscard, showResolveFive, + showDiscardToHandLimit, playingFromDeck, waitingForOpponentToPlayFromDeck, waitingForOpponentToStalemate, @@ -801,6 +812,7 @@ export const useGameStore = defineStore('game', () => { requestPlayTargetedOneOff, requestPlayJack, requestDiscard, + requestDiscardToHandLimit, requestResolve, requestResolveThree, requestResolveFive, diff --git a/src/translations/en.json b/src/translations/en.json index 7c938b570..99b185d13 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -122,6 +122,11 @@ "discard": "Discard", "opponentHasResolved": "Your Opponent has resolved a Four One-Off. You must discard two cards. Click to select cards to discard." }, + "discardToHandLimit": { + "title": "Discard Excess Cards", + "body": "You have more than 8 cards in hand. Discard down to the hand limit of 8.", + "discard": "Discard" + }, "five": { "discardAndDraw": "Discard and Draw", "nice": "Nice!", @@ -339,6 +344,11 @@ "notResolvingFourPhase": "Can't discard unless your opponent played a four", "mustSelectCards": "You must select two cards to discard" }, + "discardToHandLimit": { + "notDiscardingPhase": "You can only discard to the hand limit during the discard phase", + "handNotOverLimit": "Your hand is not over the limit", + "mustSelectCards": "You must select cards to discard down to the hand limit" + }, "five": { "selectCardToDiscard": "You must select one card to discard", "incorrectCard": "Incorrect card played", diff --git a/tests/e2e/specs/in-game/basicMoves.spec.js b/tests/e2e/specs/in-game/basicMoves.spec.js index 25cff7fcb..7abd644db 100644 --- a/tests/e2e/specs/in-game/basicMoves.spec.js +++ b/tests/e2e/specs/in-game/basicMoves.spec.js @@ -467,14 +467,69 @@ describe('Game Basic Moves - P1 Perspective', () => { cy.drawCardOpponent(); // Opponent now has 8 cards in hand cy.get('[data-opponent-hand-card]').should('have.length', 8); - // Player attempts to draw with full hand + // Player draws with full hand - now has 9 cards, triggering discard-to-hand-limit dialog cy.get('#deck').click(); - // Test that Error snackbar for hand limit - assertSnackbar('You are at the hand limit; you cannot draw.'); - // Player still has 8 cards in hand + // Player now has 9 cards in hand + cy.get('[data-player-hand-card]').should('have.length', 9); + // Discard-to-hand-limit dialog appears + cy.get('#discard-to-hand-limit-dialog').should('be.visible'); + // Player selects one card to discard + cy.get('[data-discard-hand-limit-card]').first() + .click(); + cy.get('[data-cy=submit-discard-to-hand-limit-dialog]').click(); + // Player is back to 8 cards in hand cy.get('[data-player-hand-card]').should('have.length', 8); - // Opponent still has 8 cards in hand - cy.get('[data-opponent-hand-card]').should('have.length', 8); + // Dialog is gone + cy.get('#discard-to-hand-limit-dialog').should('not.exist'); + }); + + it('Draws at hand limit triggers discard dialog (P0 perspective)', () => { + cy.loadGameFixture(1, { + p0Hand: [ + Card.ACE_OF_CLUBS, + Card.TWO_OF_CLUBS, + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, + Card.NINE_OF_CLUBS, + ], + p0Points: [], + p0FaceCards: [], + p1Hand: [ Card.ACE_OF_SPADES ], + p1Points: [], + p1FaceCards: [], + topCard: Card.TEN_OF_CLUBS, + secondCard: Card.JACK_OF_CLUBS, + deck: [ Card.QUEEN_OF_CLUBS ], + }); + // Player (P1) draws - passing turn to P0 + cy.get('#deck').click(); + // Opponent (P0) draws with a full hand of 8 + cy.drawCardOpponent(); + // Opponent now has 9 cards - wait for their discard + cy.get('#waiting-for-opponent-discard-scrim').should('be.visible'); + cy.discardToHandLimitOpponent(Card.ACE_OF_CLUBS); + cy.get('#waiting-for-opponent-discard-scrim').should('not.exist'); + assertGameState(1, { + p0Hand: [ + Card.TWO_OF_CLUBS, + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, + Card.NINE_OF_CLUBS, + Card.TEN_OF_CLUBS, + ], + p0Points: [], + p0FaceCards: [], + p1Hand: [ Card.ACE_OF_SPADES, Card.JACK_OF_CLUBS ], + p1Points: [], + p1FaceCards: [], + scrap: [ Card.ACE_OF_CLUBS ], + }); }); it('draws last card from deck, and displays snackbar', () => { diff --git a/tests/e2e/specs/in-game/handLimit.spec.js b/tests/e2e/specs/in-game/handLimit.spec.js new file mode 100644 index 000000000..d138f2410 --- /dev/null +++ b/tests/e2e/specs/in-game/handLimit.spec.js @@ -0,0 +1,226 @@ +import { assertGameState } from '../../support/helpers'; +import { Card } from '../../fixtures/cards'; + +describe('Hand Limit — Discard to Hand Limit Phase', () => { + describe('P0 draws at hand limit', () => { + beforeEach(() => { + cy.setupGameAsP0(); + }); + + it('Player draws 9th card and discards one to reach hand limit', () => { + cy.loadGameFixture(0, { + p0Hand: [ + Card.ACE_OF_CLUBS, + Card.TWO_OF_CLUBS, + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, + Card.NINE_OF_CLUBS, + ], + p0Points: [], + p0FaceCards: [], + p1Hand: [ Card.ACE_OF_SPADES ], + p1Points: [], + p1FaceCards: [], + topCard: Card.TEN_OF_CLUBS, + secondCard: Card.JACK_OF_CLUBS, + deck: [ Card.QUEEN_OF_CLUBS ], + }); + + // Player draws with a full hand of 8 + cy.get('#deck').click(); + // Player now has 9 cards in hand + cy.get('[data-player-hand-card]').should('have.length', 9); + + // Discard-to-hand-limit dialog appears + cy.get('#discard-to-hand-limit-dialog').should('be.visible'); + + // Player selects one card to discard + cy.get('[data-discard-hand-limit-card=1-0]').click(); + cy.get('[data-cy=submit-discard-to-hand-limit-dialog]').click(); + + // Player is back to 8 cards + cy.get('[data-player-hand-card]').should('have.length', 8); + cy.get('#discard-to-hand-limit-dialog').should('not.exist'); + + assertGameState(0, { + p0Hand: [ + Card.TWO_OF_CLUBS, + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, + Card.NINE_OF_CLUBS, + Card.TEN_OF_CLUBS, + ], + p0Points: [], + p0FaceCards: [], + p1Hand: [ Card.ACE_OF_SPADES ], + p1Points: [], + p1FaceCards: [], + scrap: [ Card.ACE_OF_CLUBS ], + }); + }); + }); + + describe('P1 draws at hand limit (opponent discards)', () => { + beforeEach(() => { + cy.setupGameAsP1(); + }); + + it('Opponent draws 9th card and must discard — player sees waiting overlay', () => { + cy.loadGameFixture(1, { + p0Hand: [ + Card.ACE_OF_CLUBS, + Card.TWO_OF_CLUBS, + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, + Card.NINE_OF_CLUBS, + ], + p0Points: [], + p0FaceCards: [], + p1Hand: [ Card.ACE_OF_SPADES ], + p1Points: [], + p1FaceCards: [], + topCard: Card.TEN_OF_CLUBS, + secondCard: Card.JACK_OF_CLUBS, + deck: [ Card.QUEEN_OF_CLUBS ], + }); + + // Player (P1) passes turn to P0 + cy.get('#deck').click(); + // P1 drew a card; now P0's turn + // Opponent (P0) draws with a full hand of 8 + cy.drawCardOpponent(); + + // Waiting overlay appears for player (P1) while opponent discards + cy.get('#waiting-for-opponent-discard-scrim').should('be.visible'); + + // Opponent discards one card + cy.discardToHandLimitOpponent(Card.ACE_OF_CLUBS); + + // Overlay disappears and it's now P0's turn (P0 discarded, turn passed) + cy.get('#waiting-for-opponent-discard-scrim').should('not.exist'); + + assertGameState(1, { + p0Hand: [ + Card.TWO_OF_CLUBS, + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, + Card.NINE_OF_CLUBS, + Card.TEN_OF_CLUBS, + ], + p0Points: [], + p0FaceCards: [], + p1Hand: [ Card.ACE_OF_SPADES, Card.JACK_OF_CLUBS ], + p1Points: [], + p1FaceCards: [], + scrap: [ Card.ACE_OF_CLUBS ], + }); + }); + }); + + describe('Five at 7 cards (overflows by 1)', () => { + beforeEach(() => { + cy.setupGameAsP0(); + }); + + it('Five resolves to 9 cards triggering discard of 1', () => { + cy.loadGameFixture(0, { + p0Hand: [ + Card.FIVE_OF_CLUBS, + Card.ACE_OF_CLUBS, + Card.TWO_OF_CLUBS, + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + ], + p0Points: [], + p0FaceCards: [], + p1Hand: [ Card.ACE_OF_SPADES ], + p1Points: [], + p1FaceCards: [], + topCard: Card.EIGHT_OF_CLUBS, + secondCard: Card.NINE_OF_CLUBS, + deck: [ Card.TEN_OF_CLUBS ], + }); + + // Play five and resolve — discard one card first + cy.playOneOffAndResolveAsPlayer(Card.FIVE_OF_CLUBS); + cy.get('[data-cy=five-discard-dialog]').should('be.visible'); + cy.get('[data-discard-card=1-0]').click(); + cy.get('[data-cy=submit-five-dialog]').click(); + + // Player now has 9 cards (6 remaining - 1 discarded + 3 drawn), triggering discard-to-hand-limit + cy.get('[data-player-hand-card]').should('have.length', 9); + cy.get('#discard-to-hand-limit-dialog').should('be.visible'); + + // Player discards 1 card + cy.get('[data-discard-hand-limit-card]').first() + .click(); + cy.get('[data-cy=submit-discard-to-hand-limit-dialog]').click(); + + cy.get('[data-player-hand-card]').should('have.length', 8); + cy.get('#discard-to-hand-limit-dialog').should('not.exist'); + }); + }); + + describe('Five at 8 cards (overflows by 2)', () => { + beforeEach(() => { + cy.setupGameAsP0(); + }); + + it('Five resolves to 10 cards triggering discard of 2', () => { + cy.loadGameFixture(0, { + p0Hand: [ + Card.FIVE_OF_CLUBS, + Card.ACE_OF_CLUBS, + Card.TWO_OF_CLUBS, + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, + ], + p0Points: [], + p0FaceCards: [], + p1Hand: [ Card.ACE_OF_SPADES ], + p1Points: [], + p1FaceCards: [], + topCard: Card.NINE_OF_CLUBS, + secondCard: Card.TEN_OF_CLUBS, + deck: [ Card.JACK_OF_CLUBS ], + }); + + // Play five and resolve — discard one card first (8 cards → 7 after discard → 10 after drawing 3) + cy.playOneOffAndResolveAsPlayer(Card.FIVE_OF_CLUBS); + cy.get('[data-cy=five-discard-dialog]').should('be.visible'); + cy.get('[data-discard-card=1-0]').click(); + cy.get('[data-cy=submit-five-dialog]').click(); + + // Player now has 10 cards (7 remaining - 1 discarded + 3 drawn), must discard 2 + cy.get('[data-player-hand-card]').should('have.length', 10); + cy.get('#discard-to-hand-limit-dialog').should('be.visible'); + + // Player must select 2 cards to discard + cy.get('[data-discard-hand-limit-card]').eq(0) + .click(); + cy.get('[data-discard-hand-limit-card]').eq(1) + .click(); + cy.get('[data-cy=submit-discard-to-hand-limit-dialog]').click(); + + cy.get('[data-player-hand-card]').should('have.length', 8); + cy.get('#discard-to-hand-limit-dialog').should('not.exist'); + }); + }); +}); diff --git a/tests/e2e/specs/in-game/one-offs/5_fives.spec.js b/tests/e2e/specs/in-game/one-offs/5_fives.spec.js index bd304edd8..f86f54da0 100644 --- a/tests/e2e/specs/in-game/one-offs/5_fives.spec.js +++ b/tests/e2e/specs/in-game/one-offs/5_fives.spec.js @@ -141,8 +141,8 @@ describe('FIVES', () => { cy.get('[data-player-hand-card]').should('have.length', 2); }); - it('Plays a 5 to draw two cards when already at hand limit (8)', () => { - // Setup: there are three cards in the deck and player has a 5 + it('Plays a 5 to draw three cards when already at hand limit (8) and must discard one', () => { + // Setup: there are three cards in the deck and player has 8 cards including a 5 cy.loadGameFixture(0, { p0Hand: [ Card.FIVE_OF_CLUBS, @@ -165,41 +165,24 @@ describe('FIVES', () => { }); cy.get('#deck').should('contain', '(3)'); - // Play 5 and resolve + // Play 5 and resolve — discard FIVE_OF_SPADES before drawing cy.playOneOffAndResolveAsPlayer(Card.FIVE_OF_CLUBS); cy.get('[data-cy=five-discard-dialog]').should('be.visible'); cy.get('[data-discard-card=5-3]').click(); cy.get('[data-cy=submit-five-dialog]').click(); - assertGameState(0, { - p0Hand: [ - Card.THREE_OF_CLUBS, - Card.EIGHT_OF_HEARTS, - Card.ACE_OF_DIAMONDS, - Card.EIGHT_OF_CLUBS, - Card.TEN_OF_CLUBS, - Card.TWO_OF_CLUBS, - Card.THREE_OF_HEARTS, - Card.FOUR_OF_HEARTS, - ], - p0Points: [], - p0FaceCards: [], - p1Hand: [ Card.TWO_OF_HEARTS ], - p1Points: [], - p1FaceCards: [], - scrap: [ Card.FIVE_OF_CLUBS, Card.FIVE_OF_SPADES, Card.ACE_OF_CLUBS, - Card.FOUR_OF_CLUBS, Card.SIX_OF_CLUBS, Card.SEVEN_OF_CLUBS, Card.NINE_OF_CLUBS, - Card.JACK_OF_CLUBS, Card.QUEEN_OF_CLUBS, Card.KING_OF_CLUBS, Card.TWO_OF_DIAMONDS, - Card.THREE_OF_DIAMONDS, Card.FOUR_OF_DIAMONDS, Card.FIVE_OF_DIAMONDS, Card.SIX_OF_DIAMONDS, - Card.SEVEN_OF_DIAMONDS, Card.EIGHT_OF_DIAMONDS, Card.NINE_OF_DIAMONDS, Card.TEN_OF_DIAMONDS, - Card.JACK_OF_DIAMONDS, Card.QUEEN_OF_DIAMONDS, Card.KING_OF_DIAMONDS, Card.ACE_OF_HEARTS, - Card.FIVE_OF_HEARTS, Card.SIX_OF_HEARTS, Card.SEVEN_OF_HEARTS, Card.NINE_OF_HEARTS, - Card.TEN_OF_HEARTS, Card.JACK_OF_HEARTS, Card.QUEEN_OF_HEARTS, Card.KING_OF_HEARTS, - Card.TWO_OF_SPADES, Card.THREE_OF_SPADES, Card.FOUR_OF_SPADES, - Card.SIX_OF_SPADES, Card.SEVEN_OF_SPADES, Card.EIGHT_OF_SPADES, Card.NINE_OF_SPADES, - Card.TEN_OF_SPADES, Card.JACK_OF_SPADES, Card.QUEEN_OF_SPADES, Card.KING_OF_SPADES ], - }); - cy.get('#deck').should('contain', '(1)'); + // Player now has 9 cards (7 remaining - 1 discarded + 3 drawn), triggering discard-to-hand-limit + cy.get('[data-player-hand-card]').should('have.length', 9); + cy.get('#discard-to-hand-limit-dialog').should('be.visible'); + + // Discard one card to get back to 8 + cy.get('[data-discard-hand-limit-card=1-1]').click(); + cy.get('[data-cy=submit-discard-to-hand-limit-dialog]').click(); + + // Player is back at 8 cards, deck is empty (all 3 drawn) + cy.get('[data-player-hand-card]').should('have.length', 8); + cy.get('#discard-to-hand-limit-dialog').should('not.exist'); + cy.get('#deck').should('contain', '(0)'); }); it('Draws only 1 card when last card in deck', () => { diff --git a/tests/e2e/specs/in-game/one-offs/9_nines.spec.js b/tests/e2e/specs/in-game/one-offs/9_nines.spec.js index 5d16f1031..57d92c611 100644 --- a/tests/e2e/specs/in-game/one-offs/9_nines.spec.js +++ b/tests/e2e/specs/in-game/one-offs/9_nines.spec.js @@ -806,4 +806,97 @@ describe('Playing NINES', () => { }); }); }); // End Opponent playing NINES describe + + describe('Nine triggers discard-to-hand-limit', () => { + beforeEach(() => { + cy.setupGameAsP0(); + }); + + it('Nine returns card to opponent hand at hand limit, triggering discard dialog for opponent', () => { + cy.loadGameFixture(0, { + p0Hand: [ Card.NINE_OF_SPADES ], + p0Points: [], + p0FaceCards: [], + p1Hand: [ + Card.ACE_OF_HEARTS, + Card.TWO_OF_HEARTS, + Card.THREE_OF_HEARTS, + Card.FOUR_OF_HEARTS, + Card.FIVE_OF_HEARTS, + Card.SIX_OF_HEARTS, + Card.SEVEN_OF_HEARTS, + Card.EIGHT_OF_HEARTS, + ], + p1Points: [ Card.TEN_OF_DIAMONDS ], + p1FaceCards: [], + }); + + // Player plays nine as targeted one-off against opponent's point card + cy.get('[data-player-hand-card=9-3]').click(); + cy.get('[data-move-choice=targetedOneOff]').click(); + cy.get('#player-hand-targeting').should('be.visible'); + cy.get('[data-opponent-point-card=10-1]').click(); + cy.resolveOpponent(); + + // Opponent now has 9 cards (8 in hand + TEN_OF_DIAMONDS returned), must discard + cy.get('#waiting-for-opponent-discard-scrim').should('be.visible'); + cy.discardToHandLimitOpponent(Card.ACE_OF_HEARTS); + cy.get('#waiting-for-opponent-discard-scrim').should('not.exist'); + + assertGameState(0, { + p0Hand: [], + p0Points: [], + p0FaceCards: [], + p1Hand: [ + Card.TWO_OF_HEARTS, + Card.THREE_OF_HEARTS, + Card.FOUR_OF_HEARTS, + Card.FIVE_OF_HEARTS, + Card.SIX_OF_HEARTS, + Card.SEVEN_OF_HEARTS, + Card.EIGHT_OF_HEARTS, + Card.TEN_OF_DIAMONDS, + ], + p1Points: [], + p1FaceCards: [], + scrap: [ Card.NINE_OF_SPADES, Card.ACE_OF_HEARTS ], + }); + }); + + it('Nine returns card to player hand at hand limit, triggering discard dialog for player', () => { + cy.loadGameFixture(0, { + p0Hand: [ + Card.ACE_OF_CLUBS, + Card.TWO_OF_CLUBS, + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, + Card.NINE_OF_CLUBS, + ], + p0Points: [ Card.TEN_OF_CLUBS ], + p0FaceCards: [], + p1Hand: [ Card.NINE_OF_SPADES ], + p1Points: [], + p1FaceCards: [], + }); + + // Opponent plays nine targeting player's point card + cy.playTargetedOneOffOpponent(Card.NINE_OF_SPADES, Card.TEN_OF_CLUBS, 'point'); + cy.get('#cannot-counter-dialog').should('be.visible') + .get('[data-cy=cannot-counter-resolve]') + .click(); + + // Player now has 9 cards (8 in hand + TEN_OF_CLUBS returned), must discard + cy.get('#discard-to-hand-limit-dialog').should('be.visible'); + cy.get('[data-discard-hand-limit-card]').first() + .click(); + cy.get('[data-cy=submit-discard-to-hand-limit-dialog]').click(); + + // Player is back to 8 cards, it's now player's turn + cy.get('[data-player-hand-card]').should('have.length', 8); + cy.get('#discard-to-hand-limit-dialog').should('not.exist'); + }); + }); // End Nine triggers discard-to-hand-limit }); diff --git a/tests/e2e/support/commands.js b/tests/e2e/support/commands.js index 32c2eb91f..b28fb5381 100644 --- a/tests/e2e/support/commands.js +++ b/tests/e2e/support/commands.js @@ -39,6 +39,7 @@ const transformGameUrl = (api, slug, gameId = null) => { case 'resolveThree': case 'resolveFour': case 'resolveFive': + case 'discardToHandLimit': case 'seven/points': case 'seven/scuttle': case 'seven/faceCard': @@ -474,6 +475,21 @@ Cypress.Commands.add('resolveOpponent', (gameId = null) => { * @param card1 {suit: number, rank: number} OPTIONAL * @param card2 {suit: number, rank: number} OPTIONAL */ +Cypress.Commands.add('discardToHandLimitOpponent', (card1, card2 = null) => { + const moveType = MoveType.DISCARD_TO_HAND_LIMIT; + transformGameUrl('game', 'discardToHandLimit').then((url) => { + io.socket.request({ + method: 'post', + url, + data: { + moveType, + cardId1: card1?.id, + ...(card2 && { cardId2: card2.id }), + }, + }); + }); +}); + Cypress.Commands.add('discardOpponent', (card1, card2) => { const moveType = MoveType.RESOLVE_FOUR; diff --git a/types/SocketEvent.js b/types/SocketEvent.js index 6834d2c83..ad548ffb4 100644 --- a/types/SocketEvent.js +++ b/types/SocketEvent.js @@ -19,6 +19,7 @@ export default { RESOLVE_THREE: 'resolveThree', RESOLVE_FOUR: 'resolveFour', RESOLVE_FIVE: 'resolveFive', + DISCARD_TO_HAND_LIMIT: 'discardToHandLimit', SCUTTLE: 'scuttle', SEVEN_FACE_CARD: 'sevenFaceCard', SEVEN_JACK: 'sevenJack', diff --git a/utils/GamePhase.json b/utils/GamePhase.json index bc1e97a8f..a213e2a88 100644 --- a/utils/GamePhase.json +++ b/utils/GamePhase.json @@ -4,6 +4,7 @@ "RESOLVING_THREE" : 3, "RESOLVING_FOUR" : 4, "RESOLVING_FIVE": 5, + "DISCARDING_TO_HAND_LIMIT": 6, "RESOLVING_SEVEN": 7, "CONSIDERING_STALEMATE": -1 } diff --git a/utils/MoveType.json b/utils/MoveType.json index 351d62202..05e5a7f81 100644 --- a/utils/MoveType.json +++ b/utils/MoveType.json @@ -12,6 +12,7 @@ "RESOLVE_THREE": "resolveThree", "RESOLVE_FOUR": "resolveFour", "RESOLVE_FIVE": "resolveFive", + "DISCARD_TO_HAND_LIMIT": "discardToHandLimit", "SEVEN_POINTS": "sevenPoints", "SEVEN_SCUTTLE": "sevenScuttle", "SEVEN_FACE_CARD": "sevenFaceCard", From 08ce87723f5b5b572b29ec72fd5578bc48c6d031 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Sat, 2 May 2026 12:35:25 -0400 Subject: [PATCH 02/10] docs(game-state-api.md): init --- docs/game-state-api.md | 437 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 docs/game-state-api.md diff --git a/docs/game-state-api.md b/docs/game-state-api.md new file mode 100644 index 000000000..0d0aefa99 --- /dev/null +++ b/docs/game-state-api.md @@ -0,0 +1,437 @@ +# Move History and Gamestate API + +This doc outlines a proposal for the database and api payload architecture of individual moves and the resulting game states in cuttle.cards. This is intended to facilitate a variety of features that leverage more granular data collection (ie the game state after each move) and decouple the representation of where cards are located from the representation of the users so a given user can be in more than one game at once. In particular this will make possible: + +* Building the vs AI experience into the main application +* Watchable game replayed of completed games ✅ +* Async play +* Strategic analysis of gameplay + +# Database Layer + +## Game + +The `Game` object/table is reduced to the metadata about the game that doesn’t generally change while a game is played. It has the game’s id, name, the ids of which users are p0 and p1. + +1. id: primary key +2. name: `String` +3. p0: `int` fk (user) \- id of which user is player 0 +4. p1: `int` fk (user) \- id of which user is player 1 +5. p0Ready: `boolean` \- whether p0 is ready for the game to start +6. p1Ready: `boolean` \- whether p1 is ready for the game to start +7. status: `int` (enum) \- what state of the game lifecycle we are in + 1. "CREATED" : 1, + 2. "STARTED" : 2, + 3. "FINISHED" : 3, + 4. "ARCHIVED" : 4 +8. lock: `String` \- UUID specifying which request currently locked the game for updates +9. lockedAt: `timestampz` \- Time the game was last locked +10. winner: `int` fk (user) \- which user won the game +11. match: `int` fk(match) \- which match the game is part of + +## GameStateRow + +A GameState record represents one move made by a player and the resulting game state. It contains a breadth of data that can be used to power various features and analysis within a given game and across games. GameStateRows are compressed using domain-specific string\[\] lists to represent the cards. They can be uncompressed into the `GameState` object with the `unpackGameState()` helper. + +1. id: primary key +2. playedBy: `int` 0 | 1: Which player made the move (0 if p0, 1 if p1) +3. moveType: `Enum (int?)` \- designates which kind of move was made. Corresponds to one of the following: + 1. draw + 2. points + 3. scuttle + 4. faceCard (king, queen, or glasses eight) + 5. jack + 6. untargetedOneOff + 7. targetedOneOff + 8. counter + 9. resolve + 10. resolveThree (picking a card from the scrap) + 11. resolveFour (discarding from hand) + 12. sevenPoints + 13. sevenScuttle + 14. sevenFaceCard + 15. sevenJack + 16. sevenUntargetedOneOff + 17. sevenTargetedOneOff + 18. pass +4. turn: `int` \- which turn number the move was made on +5. phase: `enum` \- What phase of a turn the game is currently in. Used to validate which next-moves are legal + 1. main \- ‘main’ player phase. Can make ‘regular’ moves: + 1. draw + 2. points + 3. faceCard + 4. jack + 5. scuttle + 6. untargetedOneOff + 7. targetedOneOff + 2. countering \- phase where players play counters. Only legal moves are + 1. counter + 2. resolve + 3. resolvingThree \- Picking card from scrap. Only legal move is resolveThree + 4. resolvingFour \- Choosing cards to discard. Only legal move is resolveFour + 5. resolvingFive \- Choosing card to discard before drawing. Only legal move is resolveFive + 6. resolvingSeven \- Picking one of the top cards from the deck. Must play a seven move next. Legal next moves: + 1. sevenPoints + 2. sevenFaceCard + 3. sevenScuttle + 4. sevenUntargetedOneOff + 5. sevenTargetedOneOff + 6. sevenJack +6. playedCard: `String | null` the card that was played + 1. Can be null +7. targetCard: `String | null` the card that was targeted + 1. Can be null +8. discardedCards: `Array` +9. p0Hand: `Array` + 1. Specially formatted string representing the list of cards in p0’s hand. See [‘Game State Array\ Lists](#game-state-array\-lists) below for full explanation + 2. ex) `[‘AC’, ‘5H’, ‘8D(JS-p0,JD-p1,JH-p0)’]` + 1. P0 hand has the Ace of Clubs, 5 of Hearts, 8 of Diamonds + 1. 8 of diamonds has the jack of spades, jack of diamonds, and jack of hearts on it +10. p1Hand: `Array`: Cards in p1 hand +11. p0Points: `Array`: Cards in p0 points +12. p1Points: `Array` +13. p0FaceCards: `Array` +14. p1FaceCards: `Array` +15. deck: `Array`: Cards in the deck, in order (this removes the need for topCard and secondCard) +16. scrap: `Array` +17. oneOff: `String | null` +18. oneOffTarget: `String | null` +19. twos: `Array` +20. resolving: `String` +21. gameId: ID \- FK to the games table +22. createdAt: `timestampz` \- When the move took place + +## Game State Array\ Lists {#game-state-array-lists} + +Several columns on the `gamestate` table describe a list of cards in a specific place: +`p0Hand`, `p0Points`, `p0FaceCards`, `p1Hand`, `p1Points`, `p1FaceCards`, `scrap`, and `deck`. +Each of these columns contains a list of strings that specify which cards appear in which order in that list, and which cards, if any, are attached to each of those cards. Each card is specified by a `String` id that describes the cards suit and rank, as follows: + +**Suit**: ‘C’ (Clubs), ‘D’ (Diamonds), ‘H’ (Hearts), ‘S’ (Spades) +**Rank:** ‘A’ (Ace), ‘2’, ‘3’, ‘4’, ‘5’, ‘6’, ‘7’, ‘8’, ‘9’, ‘T’ (Ten), ‘J’ (Jack), ‘Q’ (Queen), ‘K’ (King) + +So ‘AC’ is the Ace of Clubs, ‘4D’ is the Four of Diamonds, and ‘TH’ is the Ten of Spades. + +Jacks that are attached to a card are represented with parenthese, where each attach card is listed as the card id (same pattern as above), and then ‘-’ followed by either ‘p0’ or ‘p1’ depending on who controls the card. So for example: + +`p0Points: [‘3D’, ‘4S(JS-p0)’, ‘TH(JH-p0,JC-p1,JD-p0)’]` +Indicates that player 0 has 3 point cards: + +* Three of Diamonds +* Four of Spades + * Has the Jack of Spades (controlled by p0) attached to it +* Ten of Hearts + * Jack of Hearts (p0’s-bottom jack) + * Jack of Clubs (p1’s) + * Jack of Diamonds (p0’s-top jack) + +Jacks are listed from bottom to top order, meaning that the last jack in the list is always the last one played and the one that determines who ultimately controls the point card. + +# Backend Processing Layer + +## Card + +Represents a single card, in its object format. + +1. id: `String` (same format as the gameStateRow cards e.g. ‘AS’ for Ace of Spades) +2. suit: `0 | 1 | 2 | 3` +3. rank: `[1- 13]` +4. isFrozen: `Boolean` \- whether the card is currently frozen due to a 9 and can’t be played this turn + +## Player + +Represents one player in the game. + +1. hand: `Array` +2. points: `Array` +3. faceCards: `Array` + +## GameState + +An uncompressed, object-oriented representation of a `GameStateRow` using the `saveGameState()` helper. Conversely, a `GameStateRow` can be converted to a `GameState` object using the `unpackGameState()` helper. Generally, game logic is processed using `GameState` objects and then the result is converted back to a `GameStateRow` to be persisted in the database. + +1. id: primary key +2. playedBy: `int?` 0 | 1 | null: Which player made the move (0 if p0, 1 if p1) +3. moveType: `Enum (int?)` \- designates which kind of move was made. Corresponds to one of the following: + 1. initialize + 2. draw + 3. points + 4. scuttle + 5. faceCard (king, queen, or glasses eight) + 6. jack + 7. untargetedOneOff + 8. targetedOneOff + 9. counter + 10. resolve + 11. resolveThree (picking a card from the scrap) + 12. resolveFour (discarding from hand) + 13. sevenPoints + 14. sevenScuttle + 15. sevenFaceCard + 16. sevenJack + 17. sevenUntargetedOneOff + 18. sevenTargetedOneOff + 19. Pass +4. playedCard: `Card | null` \- which card was played if any in this latest move. This is not a “place” the card can be (as the card will be in the player’s points/wherever it was played), but is rather a description of which card was played for reference and logging +5. targetCardId: `Card | null` \- which card was targeted by the current move. Used for 2’s, 9’s, jacks, and scuttling. This is not a “place” that cards can go, but rather a description of which card was targeted, for reference and logging +6. discardedCards: `Array` \- which cards, if any were discarded from this move via resolving a 4 or 5\. This is not a “place” cards can exist on the board (as these cards will actually be in the scrap), but rather a description of which cards were discarded for reference and logging +7. turn: `int` \- which turn number the move was made on +8. phase: `enum` \- What phase of a turn the game is currently in. Used to validate which next-moves are legal + 1. main \- ‘main’ player phase. Can make ‘regular’ moves: + 1. draw + 2. points + 3. faceCard + 4. jack + 5. scuttle + 6. untargetedOneOff + 7. targetedOneOff + 2. countering \- phase where players play counters. Only legal moves are + 1. counter + 2. resolve + 3. resolvingThree \- Picking card from scrap. Only legal move is resolveThree + 4. resolvingFour \- Choosing cards to discard. Only legal move is resolveFour + 5. resolvingFive \- Choosing card to discard before drawing. Only legal move is resolveFive + 6. resolvingSeven \- Picking one of the top cards from the deck. Must play a seven move next. Legal next moves: + 1. sevenPoints + 2. sevenFaceCard + 3. sevenScuttle + 4. sevenUntargetedOneOff + 5. sevenTargetedOneOff + 6. sevenJack +9. p0: `Player` +10. p1: `Player` +11. deck: `Array`: Cards in the deck, in order (this removes the need for topCard and secondCard) +12. scrap: `Array` \- the cards currently in the scrap +13. oneOff: `Card | null` \- the current one-off that is waiting to resolve or be countered. Playing a oneOff removes the card from the player’s hand and puts it here while players counter back and forth until someone resolves, at which point the oneOff and all twos are scrapped and the effect resolves or fizzles base on the number of twos played +14. oneOffTarget: `Card | null` \- when a 2 or 9 one-off is played, this describes which card is targeted by its effect +15. oneOffTargetType `‘point’ | ‘faceCard’ | ‘jack’` + 1. When the current oneOff is a 2/9, this describes where the target is located e.g. if the target is the 8 of hearts, is that a point card or face card (glasses) +16. twos: `Array` \- array of twos that have been played to counter the current oneOff +17. resolved: `Card | null` \- for a MoveType.RESOLVE move, this describes which oneOff just resolved or fizzled. This is not considered a “place” the cards can exist as the card is in the scrap at that point. Instead it’s just a description of which oneOff just resolved/fizzled. +18. gameId: ID \- FK to the games table +19. createdAt: `timestampz` \- When the move took place + +# Api Layer + +## Socket Payload + +1. moveType: enum +2. playerId: int +3. playedCard?: Object (the card that was played) +4. targetCard?: Object (the card that was targeted) +5. attachedToTarget?: Object (the point card that the target was attached to if the target was a jack) +6. gameOver: boolean +7. game: Object (full game state) + 1. id: int + 2. name: string + 3. status: enum + 1. CREATED + 2. STARTED + 3. FINISHED + 4. ARCHIVED + 4. players: Array\ + 5. passes: int + 6. deck: Array\ + 7. scrap: Array\ + 8. oneOff: Card | null + 9. oneOffTarget + 1. nullable, card object | player + 10. oneOffTargetType: enum + 1. PLAYER + 2. POINT\_CARD + 3. FACE\_CARD + 4. JACK + 11. attachedToTarget: Card | null + 12. twos : Array\ + 13. turn: int + 14. log + 1. array of moves + 15. spectatingUsers + 1. array of users +8. \ + 1. Hand Array\ + 2. Points Array\ + 3. Face Cards Array\ + 4. Name String + 5. ID Int +9. \ + + 1. id: Int + 2. suit: int + 3. rank: int + 4. Frozen: Bool + +# Backend Control Flow + +Generally, requests to make moves on the game will lock the requested game to prevent other requests from updating it, retrieve the latest game state, validate the requested move, make the requested changes, persist the changes in the database, and emit a socket event with the resulting state. This is done via a single endpoint in `api/controllers/game/move`. It delegates to move-specific helpers based on the `moveType` specified in the request body. These `execute()` and `validate()` helpers for each move live in a folder named after the move inside `api/helpers/gamestate/moves` e.g. `api/helpers/gamestate/moves/draw/execute.js` + +// api/controller/game/move + // Request to make a move +// Which move is requested is determined by req.body.moveType +// Request to make a move +module.exports \= async function (req, res) { + let game; + try { + const { saveGamestate, publishGameState, unpackGamestate } \= sails.helpers.gamestate; + // Use the execute and validate helpers specific to the requested moveType + const { execute, validate } \= sails.helpers.gamestate.moves\[req.body.moveType\]; + + game \= await sails.helpers.lockGame(req.params.gameId); + const gameState \= unpackGamestate(game.gameStates.at(\-1)); + if (\!game.gameStates.length || \!gameState) { + throw new Error({ message: 'Game has not yet started' }); + } + + // Verify whether user is in requested game and as which player + let playedBy; + switch (req.session.usr) { + case game.p0.id: + playedBy \= 0; + break; + case game.p1.id: + playedBy \= 1; + break; + default: + throw new Error('You are not a player in this game\!'); + } + + validate(gameState, req.body, playedBy); + const updatedState \= execute(gameState, req.body, playedBy); + const gameStateRow \= await saveGamestate(updatedState); + game.gameStates.push(gameStateRow); + await publishGameState(game, updatedState); + await sails.helpers.unlockGame(game.lock); + + return res.ok(); + } catch (err) { + //unlock game if failing due to validation + if (game?.lock) { + try { + await sails.helpers.unlockGame(game.lock); + } catch (err) { + //fall through for generic error handling + } + } + return res.badRequest({ message: err.message }); + } +}; + +The `validate` helper functions (e.g. `sails.helpers.gamestate.move.draw.validate()`) will be MoveType-specific utilities that throw errors if the move is illegal e.g. because it is not your turn or because another move e.g. countering is ongoing. +// api/helpers/moves/draw/validate.js +const GameStatus \= require('../../utils/GameStatus.json'); +module.exports \= { + friendlyName: 'Validate whether draw is legal', + + description: + 'Throws error if it is illegal for the requesting player to draw a card', + + inputs: { + currentState: { + type: 'ref', + description: 'The latest game state before the requesting player draws a card', + required: true, + }, + requestedMove: { + type: 'ref', + description: 'The move being requested. Specifies which player is asking to draw a card' + } + }, + sync: true, // synchronous helper + fn: ({ currentState, requestedMove }, exits) \=\> { + if (\!currentState) { + return exits.error({ message: 'Cannot draw because this game has not started yet' }); + } + + switch (currentState.phase) { + // Only allowed phase + case GamePhase.MAIN: + break; + case GamePhase.COUNTERING: + return exits.error({ message: 'Cannot draw while players are countering one-offs' }); + case GamePhase.RESOLVING\_THREE: + return exits.error({ message: 'Cannot draw while player is choosing card from scrap' }); + case GamePhase.RESOLVE\_FOUR: + return exits.error({ message: 'Cannot draw while player chooses what to discard from 4'}); + case GamePhase.RESOLVE\_FIVE: + return exits.error({ message: 'Cannot draw while player chooses what to discard for 5' }); + case GamePhase.RESOLVING\_SEVEN: + return exits.error({ message: 'Cannot draw while someone is playing from the top of the deck' }); + default: + return exits.error({ message: \`Cannot draw because game is corrupted. Unidentified phase: ${currentState.phase}\` }); + } + + if (requestedMove.playedBy \!== (currentState.turn) % 2) { + return exits.error({message: "It's not your turn"}); + } + + if (currentState.deck.length \< 1) { + return exits.error({message: 'No cards in deck'}); + } + + return exits.success(); + }, +}; + +The `execute()` helpers return a new GameState object that reflects the changes of a given move, including moving cards around, updating the turn, setting the phase, and which player played the move. + +// api/helpers/moves/draw/execute.js +const \_ \= require('lodash'); + +module.exports \= { + friendlyName: 'Draw a card', + + description: + 'Returns new GameState resulting from requested draw move', + + inputs: { + currentState: { + type: 'ref', + description: 'The latest game state before the requesting player draws a card', + required: true, + }, + /\*\* + \* @param {Object} requestedMove \- Object describing the request to draw + \* @param {1 | 0} requestedMove.playedBy \- Which player is drawing + \* @param { number } requestedMove.turn \- Turn number (should be 1 greater than currentState.turn) + \* @param { MoveType.DRAW } requestedMove.moveType \- Specifies that this a draw move + \*/ + requestedMove: { + type: 'ref', + description: 'The move being requested. Specifies which player is asking to draw a card', + } + }, + sync: true, // synchronous helper + fn: ({ currentState, requestedMove }, exits) \=\> { + const result \= \_.cloneDeep(currentState); + const player \= requestedMove.playedBy \=== 0 ? result.p0 : result.p1; + + // Move cards + const topCard \= result.deck.shift(); + player.hand.push(topCard); + + result \= { + ...result, + ...requestedMove, + }; + + return exits.success(result); + }, +}; + +The `emitGameState()` helper is used to send the latest game state to the client. For the MVP rollout, it will emit an event that matches the current data structure used by the client in production, e.g: + + Game.publish(\[fullGame.id\], { + change: 'points', + game: fullGame, + victory, + }); + +The published event should have the following shape: + + { + change: MoveType, + game: PopulatedGame, + victory: { gameOver: boolean, winner: boolean | null, conceded: boolean, currentMatch: Match | null } + }; + + From 6e0ed946bc62c0cfed843642a15d0c5abded466c Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Sun, 3 May 2026 07:47:16 -0400 Subject: [PATCH 03/10] refactor: switch to disardedCard in api for discarding to hand limit --- .../ai/get-move-bodies-for-move-type.js | 27 ++++++++++--------- .../moves/discard-to-hand-limit/execute.js | 19 +++++-------- .../moves/discard-to-hand-limit/validate.js | 14 +++++----- api/policies/hasValidMoveBody.js | 16 ++++++++++- .../game/components/dialogs/GameDialogs.vue | 7 +---- src/stores/game.js | 10 ++++--- tests/e2e/support/commands.js | 5 ++-- 7 files changed, 53 insertions(+), 45 deletions(-) diff --git a/api/helpers/game-states/ai/get-move-bodies-for-move-type.js b/api/helpers/game-states/ai/get-move-bodies-for-move-type.js index 5e2f2127a..d63661f43 100644 --- a/api/helpers/game-states/ai/get-move-bodies-for-move-type.js +++ b/api/helpers/game-states/ai/get-move-bodies-for-move-type.js @@ -141,20 +141,23 @@ module.exports = { break; case MoveType.DISCARD_TO_HAND_LIMIT: { - if (playerHand.length === 9) { - res = playerHand.map((card) => ({ moveType, playedBy, cardId1: card.id })); - } else { - for (let i = 0; i < playerHand.length; i++) { - for (let j = i + 1; j < playerHand.length; j++) { - res.push({ - moveType, - playedBy, - cardId1: playerHand[i].id, - cardId2: playerHand[j].id, - }); + const overflowCount = playerHand.length - 8; + if (overflowCount <= 0) {break;} + const getCombinations = (arr, k) => { + if (k === 1) {return arr.map((item) => [ item ]);} + const result = []; + for (let i = 0; i <= arr.length - k; i++) { + for (const rest of getCombinations(arr.slice(i + 1), k - 1)) { + result.push([ arr[i], ...rest ]); } } - } + return result; + }; + res = getCombinations(playerHand, overflowCount).map((combo) => ({ + moveType, + playedBy, + discardedCards: combo.map((card) => card.id), + })); break; } diff --git a/api/helpers/game-states/moves/discard-to-hand-limit/execute.js b/api/helpers/game-states/moves/discard-to-hand-limit/execute.js index 8eebeb157..8c03dd702 100644 --- a/api/helpers/game-states/moves/discard-to-hand-limit/execute.js +++ b/api/helpers/game-states/moves/discard-to-hand-limit/execute.js @@ -13,8 +13,7 @@ module.exports = { }, /** * @param { Object } requestedMove - Object describing the request to discard to hand limit - * @param { String } requestedMove.cardId1 - First card to be discarded - * @param { String } [requestedMove.cardId2] - Second card to be discarded (if needed) + * @param { string[] } requestedMove.discardedCards - IDs of cards to discard (exactly hand.length - 8) * @param { MoveType.DISCARD_TO_HAND_LIMIT } requestedMove.moveType */ requestedMove: { @@ -33,21 +32,17 @@ module.exports = { }, sync: true, fn: ({ currentState, requestedMove, playedBy }, exits) => { - const { cardId1, cardId2 } = requestedMove; + const { discardedCards: discardIds } = requestedMove; let result = _.cloneDeep(currentState); const player = playedBy ? result.p1 : result.p0; const discardedCards = []; - const cardIndex1 = player.hand.findIndex(({ id }) => id === cardId1); - discardedCards.push(player.hand[cardIndex1]); - result.scrap.push(...player.hand.splice(cardIndex1, 1)); - - if (cardId2) { - const cardIndex2 = player.hand.findIndex(({ id }) => id === cardId2); - if (cardIndex2 !== -1) { - discardedCards.push(player.hand[cardIndex2]); - result.scrap.push(...player.hand.splice(cardIndex2, 1)); + for (const cardId of discardIds) { + const cardIndex = player.hand.findIndex(({ id }) => id === cardId); + if (cardIndex !== -1) { + discardedCards.push(player.hand[cardIndex]); + result.scrap.push(...player.hand.splice(cardIndex, 1)); } } diff --git a/api/helpers/game-states/moves/discard-to-hand-limit/validate.js b/api/helpers/game-states/moves/discard-to-hand-limit/validate.js index f2ee08980..917e7bda3 100644 --- a/api/helpers/game-states/moves/discard-to-hand-limit/validate.js +++ b/api/helpers/game-states/moves/discard-to-hand-limit/validate.js @@ -14,8 +14,7 @@ module.exports = { }, /** * @param { Object } requestedMove - Object describing the request to discard to hand limit - * @param { String } requestedMove.cardId1 - First card to be discarded - * @param { String } [requestedMove.cardId2] - Second card to be discarded (required if hand > 9) + * @param { string[] } requestedMove.discardedCards - IDs of cards to discard (must equal hand.length - 8) * @param { MoveType.DISCARD_TO_HAND_LIMIT } requestedMove.moveType */ requestedMove: { @@ -42,19 +41,20 @@ module.exports = { } const player = playedBy ? currentState.p1 : currentState.p0; + const overflowCount = player.hand.length - 8; - if (player.hand.length <= 8) { + if (overflowCount <= 0) { throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.handNotOverLimit'); } - const { cardId1, cardId2 } = requestedMove; + const { discardedCards } = requestedMove; - const selectedCard1 = player.hand.find(card => card.id === cardId1); - if (!selectedCard1) { + if (!Array.isArray(discardedCards) || discardedCards.length !== overflowCount) { throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.mustSelectCards'); } - if (player.hand.length > 9 && !player.hand.find(card => card.id === cardId2)) { + const allInHand = discardedCards.every(id => player.hand.some(card => card.id === id)); + if (!allInHand) { throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.mustSelectCards'); } diff --git a/api/policies/hasValidMoveBody.js b/api/policies/hasValidMoveBody.js index 957f0c906..69a3a0ed4 100644 --- a/api/policies/hasValidMoveBody.js +++ b/api/policies/hasValidMoveBody.js @@ -43,7 +43,7 @@ module.exports = function (req, res, next) { } return next(); - // Requires `card1` and optionally accepts `card2` + // Requires `cardId1` and optionally accepts `cardId2` case MoveType.RESOLVE_FOUR: { if (!cardId1) { @@ -60,6 +60,20 @@ module.exports = function (req, res, next) { } return next(); + // Requires a non-empty `discardedCards` array of valid card IDs + case MoveType.DISCARD_TO_HAND_LIMIT: + { + const { discardedCards } = req.body; + if (!Array.isArray(discardedCards) || discardedCards.length === 0) { + return res.badRequest({ message: 'Must specify cards to discard' }); + } + const invalidId = discardedCards.find((id) => !DeckIds.includes(id)); + if (invalidId) { + return res.badRequest({ message: `${invalidId} is not a valid cardId` }); + } + } + return next(); + // These require `cardId` and `targetId` case MoveType.JACK: case MoveType.SCUTTLE: diff --git a/src/routes/game/components/dialogs/GameDialogs.vue b/src/routes/game/components/dialogs/GameDialogs.vue index 896551937..ef63e3daf 100644 --- a/src/routes/game/components/dialogs/GameDialogs.vue +++ b/src/routes/game/components/dialogs/GameDialogs.vue @@ -163,12 +163,7 @@ export default { }); }, discardToHandLimit(cardIds) { - const [ cardId1 ] = cardIds; - const cardId2 = cardIds.length > 1 ? cardIds[1] : null; - this.gameStore.requestDiscardToHandLimit({ - cardId1, - cardId2, - }); + this.gameStore.requestDiscardToHandLimit(cardIds); }, handleError() { this.$emit('handle-error'); diff --git a/src/stores/game.js b/src/stores/game.js index 7321b6faf..971a049e4 100644 --- a/src/stores/game.js +++ b/src/stores/game.js @@ -448,6 +448,7 @@ export const useGameStore = defineStore('game', () => { case 'resolveThree': case 'resolveFour': case 'resolveFive': + case 'discardToHandLimit': case 'seven/points': case 'seven/scuttle': case 'seven/faceCard': @@ -629,10 +630,11 @@ export const useGameStore = defineStore('game', () => { const reqData = cardId2 ? { moveType, cardId1, cardId2 } : { moveType, cardId1 }; await makeSocketRequest('resolveFour', reqData); } - async function requestDiscardToHandLimit({ cardId1, cardId2 }) { - const moveType = MoveType.DISCARD_TO_HAND_LIMIT; - const reqData = cardId2 ? { moveType, cardId1, cardId2 } : { moveType, cardId1 }; - await makeSocketRequest('discardToHandLimit', reqData); + async function requestDiscardToHandLimit(cardIds) { + await makeSocketRequest('discardToHandLimit', { + moveType: MoveType.DISCARD_TO_HAND_LIMIT, + discardedCards: cardIds, + }); } async function requestResolve() { const moveType = MoveType.RESOLVE; diff --git a/tests/e2e/support/commands.js b/tests/e2e/support/commands.js index b28fb5381..8189715a0 100644 --- a/tests/e2e/support/commands.js +++ b/tests/e2e/support/commands.js @@ -475,7 +475,7 @@ Cypress.Commands.add('resolveOpponent', (gameId = null) => { * @param card1 {suit: number, rank: number} OPTIONAL * @param card2 {suit: number, rank: number} OPTIONAL */ -Cypress.Commands.add('discardToHandLimitOpponent', (card1, card2 = null) => { +Cypress.Commands.add('discardToHandLimitOpponent', (...cards) => { const moveType = MoveType.DISCARD_TO_HAND_LIMIT; transformGameUrl('game', 'discardToHandLimit').then((url) => { io.socket.request({ @@ -483,8 +483,7 @@ Cypress.Commands.add('discardToHandLimitOpponent', (card1, card2 = null) => { url, data: { moveType, - cardId1: card1?.id, - ...(card2 && { cardId2: card2.id }), + discardedCards: cards.map((card) => card.id), }, }); }); From 25d6d16d1b2421f4fb50031efc43ee806a375433 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Mon, 4 May 2026 16:45:48 -0400 Subject: [PATCH 04/10] test: fix which player is p0 and handling of empty deck in hand limit tests --- tests/e2e/specs/in-game/basicMoves.spec.js | 9 ++---- tests/e2e/specs/in-game/handLimit.spec.js | 33 +++++++++------------- tests/e2e/support/commands.js | 13 ++------- 3 files changed, 20 insertions(+), 35 deletions(-) diff --git a/tests/e2e/specs/in-game/basicMoves.spec.js b/tests/e2e/specs/in-game/basicMoves.spec.js index 7abd644db..8f8da7fe6 100644 --- a/tests/e2e/specs/in-game/basicMoves.spec.js +++ b/tests/e2e/specs/in-game/basicMoves.spec.js @@ -483,7 +483,7 @@ describe('Game Basic Moves - P1 Perspective', () => { cy.get('#discard-to-hand-limit-dialog').should('not.exist'); }); - it('Draws at hand limit triggers discard dialog (P0 perspective)', () => { + it('Draws at hand limit triggers discard dialog (P1 perspective)', () => { cy.loadGameFixture(1, { p0Hand: [ Card.ACE_OF_CLUBS, @@ -502,11 +502,8 @@ describe('Game Basic Moves - P1 Perspective', () => { p1FaceCards: [], topCard: Card.TEN_OF_CLUBS, secondCard: Card.JACK_OF_CLUBS, - deck: [ Card.QUEEN_OF_CLUBS ], }); - // Player (P1) draws - passing turn to P0 - cy.get('#deck').click(); - // Opponent (P0) draws with a full hand of 8 + // Opponent (P0) goes first and draws with a full hand of 8 cy.drawCardOpponent(); // Opponent now has 9 cards - wait for their discard cy.get('#waiting-for-opponent-discard-scrim').should('be.visible'); @@ -525,7 +522,7 @@ describe('Game Basic Moves - P1 Perspective', () => { ], p0Points: [], p0FaceCards: [], - p1Hand: [ Card.ACE_OF_SPADES, Card.JACK_OF_CLUBS ], + p1Hand: [ Card.ACE_OF_SPADES ], p1Points: [], p1FaceCards: [], scrap: [ Card.ACE_OF_CLUBS ], diff --git a/tests/e2e/specs/in-game/handLimit.spec.js b/tests/e2e/specs/in-game/handLimit.spec.js index d138f2410..ed9cf0451 100644 --- a/tests/e2e/specs/in-game/handLimit.spec.js +++ b/tests/e2e/specs/in-game/handLimit.spec.js @@ -26,7 +26,6 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { p1FaceCards: [], topCard: Card.TEN_OF_CLUBS, secondCard: Card.JACK_OF_CLUBS, - deck: [ Card.QUEEN_OF_CLUBS ], }); // Player draws with a full hand of 8 @@ -90,13 +89,9 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { p1FaceCards: [], topCard: Card.TEN_OF_CLUBS, secondCard: Card.JACK_OF_CLUBS, - deck: [ Card.QUEEN_OF_CLUBS ], }); - // Player (P1) passes turn to P0 - cy.get('#deck').click(); - // P1 drew a card; now P0's turn - // Opponent (P0) draws with a full hand of 8 + // Opponent (P0) goes first and draws with a full hand of 8 cy.drawCardOpponent(); // Waiting overlay appears for player (P1) while opponent discards @@ -105,7 +100,7 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { // Opponent discards one card cy.discardToHandLimitOpponent(Card.ACE_OF_CLUBS); - // Overlay disappears and it's now P0's turn (P0 discarded, turn passed) + // Overlay disappears and it's now P1's turn (P0 discarded, turn passed to P1) cy.get('#waiting-for-opponent-discard-scrim').should('not.exist'); assertGameState(1, { @@ -121,7 +116,7 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { ], p0Points: [], p0FaceCards: [], - p1Hand: [ Card.ACE_OF_SPADES, Card.JACK_OF_CLUBS ], + p1Hand: [ Card.ACE_OF_SPADES ], p1Points: [], p1FaceCards: [], scrap: [ Card.ACE_OF_CLUBS ], @@ -129,7 +124,7 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { }); }); - describe('Five at 7 cards (overflows by 1)', () => { + describe('Five at 8 cards (overflows by 1)', () => { beforeEach(() => { cy.setupGameAsP0(); }); @@ -144,15 +139,15 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { Card.FOUR_OF_CLUBS, Card.SIX_OF_CLUBS, Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, ], p0Points: [], p0FaceCards: [], p1Hand: [ Card.ACE_OF_SPADES ], p1Points: [], p1FaceCards: [], - topCard: Card.EIGHT_OF_CLUBS, - secondCard: Card.NINE_OF_CLUBS, - deck: [ Card.TEN_OF_CLUBS ], + topCard: Card.NINE_OF_CLUBS, + secondCard: Card.TEN_OF_CLUBS, }); // Play five and resolve — discard one card first @@ -161,7 +156,7 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { cy.get('[data-discard-card=1-0]').click(); cy.get('[data-cy=submit-five-dialog]').click(); - // Player now has 9 cards (6 remaining - 1 discarded + 3 drawn), triggering discard-to-hand-limit + // Player now has 9 cards (7 remaining - 1 discarded + 3 drawn), triggering discard-to-hand-limit cy.get('[data-player-hand-card]').should('have.length', 9); cy.get('#discard-to-hand-limit-dialog').should('be.visible'); @@ -175,7 +170,7 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { }); }); - describe('Five at 8 cards (overflows by 2)', () => { + describe('Five at 9 cards (overflows by 2)', () => { beforeEach(() => { cy.setupGameAsP0(); }); @@ -191,24 +186,24 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { Card.SIX_OF_CLUBS, Card.SEVEN_OF_CLUBS, Card.EIGHT_OF_CLUBS, + Card.NINE_OF_CLUBS, ], p0Points: [], p0FaceCards: [], p1Hand: [ Card.ACE_OF_SPADES ], p1Points: [], p1FaceCards: [], - topCard: Card.NINE_OF_CLUBS, - secondCard: Card.TEN_OF_CLUBS, - deck: [ Card.JACK_OF_CLUBS ], + topCard: Card.TEN_OF_CLUBS, + secondCard: Card.JACK_OF_CLUBS, }); - // Play five and resolve — discard one card first (8 cards → 7 after discard → 10 after drawing 3) + // Play five and resolve — discard one card first (9 cards → 8 after discard → 10 after drawing 3) cy.playOneOffAndResolveAsPlayer(Card.FIVE_OF_CLUBS); cy.get('[data-cy=five-discard-dialog]').should('be.visible'); cy.get('[data-discard-card=1-0]').click(); cy.get('[data-cy=submit-five-dialog]').click(); - // Player now has 10 cards (7 remaining - 1 discarded + 3 drawn), must discard 2 + // Player now has 10 cards (8 remaining - 1 discarded + 3 drawn), must discard 2 cy.get('[data-player-hand-card]').should('have.length', 10); cy.get('#discard-to-hand-limit-dialog').should('be.visible'); diff --git a/tests/e2e/support/commands.js b/tests/e2e/support/commands.js index 8189715a0..e7d9c31cd 100644 --- a/tests/e2e/support/commands.js +++ b/tests/e2e/support/commands.js @@ -476,16 +476,9 @@ Cypress.Commands.add('resolveOpponent', (gameId = null) => { * @param card2 {suit: number, rank: number} OPTIONAL */ Cypress.Commands.add('discardToHandLimitOpponent', (...cards) => { - const moveType = MoveType.DISCARD_TO_HAND_LIMIT; - transformGameUrl('game', 'discardToHandLimit').then((url) => { - io.socket.request({ - method: 'post', - url, - data: { - moveType, - discardedCards: cards.map((card) => card.id), - }, - }); + cy.makeSocketRequest('game', 'discardToHandLimit', { + moveType: MoveType.DISCARD_TO_HAND_LIMIT, + discardedCards: cards.map((card) => card.id), }); }); From 5d2e6fc7f7555b4e7282636e5cef67ee56adb176 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Mon, 4 May 2026 20:12:28 -0400 Subject: [PATCH 05/10] fix(translations): match en.json for hand limit --- src/translations/de.json | 5 +++++ src/translations/es.json | 5 +++++ src/translations/fr.json | 5 +++++ src/translations/ukr.json | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/src/translations/de.json b/src/translations/de.json index e4b993327..80cac7f43 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -122,6 +122,11 @@ "discard": "Abwerfen", "opponentHasResolved": "Dein Gegner hat einen Vier-Einmal-Abwurf gelöst. Du musst zwei Karten abwerfen. Klicke, um Karten auszuwählen." }, + "discardToHandLimit": { + "title": "Discard Excess Cards", + "body": "You have more than 8 cards in hand. Discard down to the hand limit of 8.", + "discard": "Discard" + }, "five": { "discardAndDraw": "Abwerfen und Ziehen", "nice": "Schön!", diff --git a/src/translations/es.json b/src/translations/es.json index b44e50d61..b6970a78c 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -133,6 +133,11 @@ "discard": "Descartar", "opponentHasResolved": "El rival ha usado la habilidad Única del Cuatro. Debes descartar dos cartas. Haz clic para seleccionar las cartas para descartar." }, + "discardToHandLimit": { + "title": "Discard Excess Cards", + "body": "You have more than 8 cards in hand. Discard down to the hand limit of 8.", + "discard": "Discard" + }, "five": { "discardAndDraw": "Descartar y robar", "nice": "¡Bien!", diff --git a/src/translations/fr.json b/src/translations/fr.json index 585035987..63986feb3 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -133,6 +133,11 @@ "discard": "Défausser", "opponentHasResolved": "Votre adversaire a joué un quatre en attaque. Vous devez défausser deux cartes. Cliquez pour sélectionner les cartes à défausser." }, + "discardToHandLimit": { + "title": "Discard Excess Cards", + "body": "You have more than 8 cards in hand. Discard down to the hand limit of 8.", + "discard": "Discard" + }, "five": { "discardAndDraw": "Jeter et piocher", "nice": "Bon!", diff --git a/src/translations/ukr.json b/src/translations/ukr.json index ec292e9bf..809633ae3 100644 --- a/src/translations/ukr.json +++ b/src/translations/ukr.json @@ -122,6 +122,11 @@ "discard": "Відкинути", "opponentHasResolved": "Ваш суперник вирішив чотирку з відривом. Ви повинні відкинути дві карти. Клікніть, щоб вибрати карти для відкидання." }, + "discardToHandLimit": { + "title": "Discard Excess Cards", + "body": "You have more than 8 cards in hand. Discard down to the hand limit of 8.", + "discard": "Discard" + }, "five": { "discardAndDraw": "Відкинути та взяти", "nice": "Класно!", From e08261ad6a5cea213c4625751115be35fbf70976 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Mon, 4 May 2026 20:25:20 -0400 Subject: [PATCH 06/10] test(9_nines.spec.js): fix it('Nine returns card to player hand at hand limit, triggering discard dialog for player' --- .../specs/in-game/one-offs/9_nines.spec.js | 89 +++++++++++-------- 1 file changed, 54 insertions(+), 35 deletions(-) diff --git a/tests/e2e/specs/in-game/one-offs/9_nines.spec.js b/tests/e2e/specs/in-game/one-offs/9_nines.spec.js index 57d92c611..e7b2099ab 100644 --- a/tests/e2e/specs/in-game/one-offs/9_nines.spec.js +++ b/tests/e2e/specs/in-game/one-offs/9_nines.spec.js @@ -805,6 +805,60 @@ describe('Playing NINES', () => { scrap: [ Card.NINE_OF_HEARTS, Card.NINE_OF_SPADES ], }); }); + + it('Nine returns card to player hand at hand limit, triggering discard dialog for player', () => { + cy.loadGameFixture(1, { + p0Hand: [ Card.NINE_OF_SPADES ], + p0Points: [], + p0FaceCards: [], + p1Hand: [ + Card.ACE_OF_CLUBS, + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.FIVE_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, + Card.NINE_OF_CLUBS, + ], + p1Points: [ Card.TEN_OF_CLUBS ], + p1FaceCards: [], + }); + + // Opponent (P0) plays nine targeting player's (P1's) point card + cy.playTargetedOneOffOpponent(Card.NINE_OF_SPADES, Card.TEN_OF_CLUBS, 'point'); + cy.get('#cannot-counter-dialog').should('be.visible') + .get('[data-cy=cannot-counter-resolve]') + .click(); + + // Player now has 9 cards (8 in hand + TEN_OF_CLUBS returned), must discard + cy.get('#discard-to-hand-limit-dialog').should('be.visible'); + cy.get('[data-discard-hand-limit-card=1-0]').click(); + cy.get('[data-cy=submit-discard-to-hand-limit-dialog]').click(); + + // Player is back to 8 cards, it's now player's turn + cy.get('[data-player-hand-card]').should('have.length', 8); + cy.get('#discard-to-hand-limit-dialog').should('not.exist'); + + assertGameState(1, { + p0Hand: [], + p0Points: [], + p0FaceCards: [], + p1Hand: [ + Card.THREE_OF_CLUBS, + Card.FOUR_OF_CLUBS, + Card.FIVE_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + Card.EIGHT_OF_CLUBS, + Card.NINE_OF_CLUBS, + Card.TEN_OF_CLUBS, + ], + p1Points: [], + p1FaceCards: [], + scrap: [ Card.NINE_OF_SPADES, Card.ACE_OF_CLUBS ] + }); + }); }); // End Opponent playing NINES describe describe('Nine triggers discard-to-hand-limit', () => { @@ -863,40 +917,5 @@ describe('Playing NINES', () => { }); }); - it('Nine returns card to player hand at hand limit, triggering discard dialog for player', () => { - cy.loadGameFixture(0, { - p0Hand: [ - Card.ACE_OF_CLUBS, - Card.TWO_OF_CLUBS, - Card.THREE_OF_CLUBS, - Card.FOUR_OF_CLUBS, - Card.SIX_OF_CLUBS, - Card.SEVEN_OF_CLUBS, - Card.EIGHT_OF_CLUBS, - Card.NINE_OF_CLUBS, - ], - p0Points: [ Card.TEN_OF_CLUBS ], - p0FaceCards: [], - p1Hand: [ Card.NINE_OF_SPADES ], - p1Points: [], - p1FaceCards: [], - }); - - // Opponent plays nine targeting player's point card - cy.playTargetedOneOffOpponent(Card.NINE_OF_SPADES, Card.TEN_OF_CLUBS, 'point'); - cy.get('#cannot-counter-dialog').should('be.visible') - .get('[data-cy=cannot-counter-resolve]') - .click(); - - // Player now has 9 cards (8 in hand + TEN_OF_CLUBS returned), must discard - cy.get('#discard-to-hand-limit-dialog').should('be.visible'); - cy.get('[data-discard-hand-limit-card]').first() - .click(); - cy.get('[data-cy=submit-discard-to-hand-limit-dialog]').click(); - - // Player is back to 8 cards, it's now player's turn - cy.get('[data-player-hand-card]').should('have.length', 8); - cy.get('#discard-to-hand-limit-dialog').should('not.exist'); - }); }); // End Nine triggers discard-to-hand-limit }); From fbf96e9b6a9c67eabcb9c4ca53db8555d995db50 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Mon, 4 May 2026 21:17:18 -0400 Subject: [PATCH 07/10] fix(translations): add game.snackbar.oneOffs.discardToHandLimit key to each file --- src/translations/de.json | 5 +++++ src/translations/es.json | 5 +++++ src/translations/fr.json | 5 +++++ src/translations/ukr.json | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/src/translations/de.json b/src/translations/de.json index 80cac7f43..92b7e1b88 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -343,6 +343,11 @@ "notResolvingFourPhase": "Kann nicht abgeworfen werden, es sei denn, dein Gegner hat eine 4 gespielt", "mustSelectCards": "Du musst zwei Karten zum Abwerfen auswählen" }, + "discardToHandLimit": { + "notDiscardingPhase": "You can only discard to the hand limit during the discard phase", + "handNotOverLimit": "Your hand is not over the limit", + "mustSelectCards": "You must select cards to discard down to the hand limit" + }, "five": { "selectCardToDiscard": "Du musst eine Karte zum Abwerfen auswählen", "incorrectCard": "Falsche Karte gespielt", diff --git a/src/translations/es.json b/src/translations/es.json index b6970a78c..d3c87bbae 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -343,6 +343,11 @@ "notResolvingFourPhase": "No es posible descartar salvo que tu oponente haya jugado un cuatro", "mustSelectCards": "Debes seleccionar dos cartas para descartar" }, + "discardToHandLimit": { + "notDiscardingPhase": "You can only discard to the hand limit during the discard phase", + "handNotOverLimit": "Your hand is not over the limit", + "mustSelectCards": "You must select cards to discard down to the hand limit" + }, "five": { "selectCardToDiscard": "Debes seleccionar una carta para descartar", "incorrectCard": "Carta incorrecta jugada", diff --git a/src/translations/fr.json b/src/translations/fr.json index 63986feb3..b87f2eaf0 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -343,6 +343,11 @@ "notResolvingFourPhase": "Vous ne pouvez pas défausser à moins que votre adversaire n'ait joué un Quatre", "mustSelectCards": "Vous devez sélectionner deux cartes à défausser" }, + "discardToHandLimit": { + "notDiscardingPhase": "You can only discard to the hand limit during the discard phase", + "handNotOverLimit": "Your hand is not over the limit", + "mustSelectCards": "You must select cards to discard down to the hand limit" + }, "five": { "selectCardToDiscard": "Vous devez sélectionner une carte à défausser", "incorrectCard": "Mauvaise carte jouée", diff --git a/src/translations/ukr.json b/src/translations/ukr.json index 809633ae3..1254ec152 100644 --- a/src/translations/ukr.json +++ b/src/translations/ukr.json @@ -343,6 +343,11 @@ "notResolvingFourPhase": "Не можна відкидати картки, якщо ваш суперник не зіграв чотвірку", "mustSelectCards": "Ви повинні вибрати дві картки для відкидання" }, + "discardToHandLimit": { + "notDiscardingPhase": "You can only discard to the hand limit during the discard phase", + "handNotOverLimit": "Your hand is not over the limit", + "mustSelectCards": "You must select cards to discard down to the hand limit" + }, "five": { "selectCardToDiscard": "Ви повинні вибрати одну картку для відкидання", "incorrectCard": "Невірна картка зіграна", From 1efd565d9571b824cbfe799cc9595d093bb5e787 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Tue, 5 May 2026 08:52:08 -0400 Subject: [PATCH 08/10] refactor(DiscardToHandLimitDialog): switch to script setup --- .../components/DiscardToHandLimitDialog.vue | 112 ++++++++---------- 1 file changed, 47 insertions(+), 65 deletions(-) diff --git a/src/routes/game/components/dialogs/components/DiscardToHandLimitDialog.vue b/src/routes/game/components/dialogs/components/DiscardToHandLimitDialog.vue index 72c3ca0ea..ed9a9a012 100644 --- a/src/routes/game/components/dialogs/components/DiscardToHandLimitDialog.vue +++ b/src/routes/game/components/dialogs/components/DiscardToHandLimitDialog.vue @@ -36,75 +36,57 @@ -