diff --git a/AGENTS.md b/AGENTS.md
index bd37dcaf8..0ddf7a208 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -67,6 +67,8 @@ In case of conflicting rules, the following hierarchy applies:
- **Discovery**: Glob `src/components` for shared, general-purpose components. Page-specific components are often located in `src/routes/[routeName]/components/`. Page entry points are in `src/routes/[routeName]/[RouteName]View.vue`.
- **UI Library**: Vuetify is used. See existing components for usage patterns.
- **Routing**: `src/router.js` defines routes and navigation guards.
+- **State Management**: Pinia is used for state management; stores are located in `src/stores` and use the setup store syntax
+- **Syntax** Use `
+
+
diff --git a/src/stores/game.js b/src/stores/game.js
index 939445a74..971a049e4 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,
@@ -443,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':
@@ -624,6 +630,12 @@ export const useGameStore = defineStore('game', () => {
const reqData = cardId2 ? { moveType, cardId1, cardId2 } : { moveType, cardId1 };
await makeSocketRequest('resolveFour', reqData);
}
+ async function requestDiscardToHandLimit(cardIds) {
+ await makeSocketRequest('discardToHandLimit', {
+ moveType: MoveType.DISCARD_TO_HAND_LIMIT,
+ discardedCards: cardIds,
+ });
+ }
async function requestResolve() {
const moveType = MoveType.RESOLVE;
await makeSocketRequest('resolve', { moveType });
@@ -763,6 +775,7 @@ export const useGameStore = defineStore('game', () => {
showResolveFour,
waitingForOpponentToDiscard,
showResolveFive,
+ showDiscardToHandLimit,
playingFromDeck,
waitingForOpponentToPlayFromDeck,
waitingForOpponentToStalemate,
@@ -801,6 +814,7 @@ export const useGameStore = defineStore('game', () => {
requestPlayTargetedOneOff,
requestPlayJack,
requestDiscard,
+ requestDiscardToHandLimit,
requestResolve,
requestResolveThree,
requestResolveFive,
diff --git a/src/translations/de.json b/src/translations/de.json
index e4b993327..92b7e1b88 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!",
@@ -338,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/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/src/translations/es.json b/src/translations/es.json
index b44e50d61..d3c87bbae 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!",
@@ -338,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 585035987..b87f2eaf0 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!",
@@ -338,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 ec292e9bf..1254ec152 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": "Класно!",
@@ -338,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": "Невірна картка зіграна",
diff --git a/tests/e2e/specs/in-game/basicMoves.spec.js b/tests/e2e/specs/in-game/basicMoves.spec.js
index 25cff7fcb..8f8da7fe6 100644
--- a/tests/e2e/specs/in-game/basicMoves.spec.js
+++ b/tests/e2e/specs/in-game/basicMoves.spec.js
@@ -467,14 +467,66 @@ 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 (P1 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,
+ });
+ // 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');
+ 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 ],
+ 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..ed9cf0451
--- /dev/null
+++ b/tests/e2e/specs/in-game/handLimit.spec.js
@@ -0,0 +1,221 @@
+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,
+ });
+
+ // 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,
+ });
+
+ // Opponent (P0) goes first and 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 P1's turn (P0 discarded, turn passed to P1)
+ 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 ],
+ p1Points: [],
+ p1FaceCards: [],
+ scrap: [ Card.ACE_OF_CLUBS ],
+ });
+ });
+ });
+
+ describe('Five at 8 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,
+ Card.EIGHT_OF_CLUBS,
+ ],
+ p0Points: [],
+ p0FaceCards: [],
+ p1Hand: [ Card.ACE_OF_SPADES ],
+ p1Points: [],
+ p1FaceCards: [],
+ topCard: Card.NINE_OF_CLUBS,
+ secondCard: 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 (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');
+
+ // 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 9 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,
+ Card.NINE_OF_CLUBS,
+ ],
+ p0Points: [],
+ p0FaceCards: [],
+ p1Hand: [ Card.ACE_OF_SPADES ],
+ p1Points: [],
+ p1FaceCards: [],
+ topCard: Card.TEN_OF_CLUBS,
+ secondCard: Card.JACK_OF_CLUBS,
+ });
+
+ // 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 (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');
+
+ // 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..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,5 +805,117 @@ 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', () => {
+ 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 ],
+ });
+ });
+
+ }); // End Nine triggers discard-to-hand-limit
});
diff --git a/tests/e2e/support/commands.js b/tests/e2e/support/commands.js
index 32c2eb91f..e7d9c31cd 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,13 @@ Cypress.Commands.add('resolveOpponent', (gameId = null) => {
* @param card1 {suit: number, rank: number} OPTIONAL
* @param card2 {suit: number, rank: number} OPTIONAL
*/
+Cypress.Commands.add('discardToHandLimitOpponent', (...cards) => {
+ cy.makeSocketRequest('game', 'discardToHandLimit', {
+ moveType: MoveType.DISCARD_TO_HAND_LIMIT,
+ discardedCards: cards.map((card) => card.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",