From cf8a96e60a4984c30448e0a97fef6ec53d97b5b5 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Thu, 17 Sep 2026 07:36:00 -0400 Subject: [PATCH 01/12] feat: rework nines to topdeck the target card A Nine one-off now puts its target on top of the deck instead of returning it to its controller's hand. The opponent's next draw is their own card coming back, so the Nine costs them a whole turn. This ships as a beta for the Spades season, announced on the home page, with a community poll toward the end of the year. Point cards, Royals/glasses, and the top Jack of a stack are legal targets. A targeted jack's host still reverts to its owner, and non-targeted attachments on a targeted point card are still scrapped. Queen rules are unchanged. Notes: - isFrozen was never persisted; it was re-derived on unpack. That derivation runs over every converted array including deck, so it had to go, or it would have stamped isFrozen on the card the nine had just topdecked. resolve/nine.js was its only producer, so the freeze mechanic is now dormant. The rest of the plumbing is left in place so a revert stays a clean git revert. - findTargetCard matches any jack in any stack while the resolver popped the top one, so a raw request naming a buried jack scrapped the wrong card. New is-top-jack helper, called from both one-off and seven-one-off validators. - AI jack move bodies omitted targetType, so the validator rejected every one the bot tried. Fixed, and nines now enumerate point-card targets. - resolve/execute never cleared the target slots on the fizzle path. - The four nine transition computeds branched on lastEventTargetType, which is always null because resolve/execute nulls oneOffTargetType before the socket event is built. Replaced with one direction per row and dropped the dead store field. - No effect can hand a card to the inactive player any more, so you can never start a turn over the hand limit. Two handLimit tests covering that are removed (their paths are covered by the Drawing and Fives blocks); the pass-triggers-discard test now seeds the over-limit hand. No schema change, so no migration is required. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- .../ai/get-move-bodies-for-move-type.js | 38 +- api/helpers/game-states/get-log.js | 2 +- api/helpers/game-states/is-top-jack.js | 32 ++ .../game-states/moves/one-off/validate.js | 9 + .../game-states/moves/resolve/execute.js | 3 + api/helpers/game-states/moves/resolve/nine.js | 39 +- .../moves/seven-one-off/validate.js | 9 + api/helpers/game-states/unpack-gamestate.js | 9 +- docs/game-rules.md | 2 +- docs/game-state-api.md | 2 +- src/routes/game/GameView.vue | 59 +-- .../data/announcementData.js | 24 +- src/stores/game.js | 4 - src/translations/de.json | 22 +- src/translations/en.json | 22 +- src/translations/es.json | 22 +- src/translations/fr.json | 22 +- src/translations/ukr.json | 22 +- tests/e2e/specs/in-game/handLimit.spec.js | 148 +----- .../one-offs/7_sevens/opponent_sevens.spec.js | 43 +- .../one-offs/7_sevens/player_sevens.spec.js | 6 +- .../specs/in-game/one-offs/9_nines.spec.js | 434 +++++++++++------- .../one-offs/targeting_cleanup.spec.js | 5 +- tests/unit/fixtures/gameStates/resolveNine.js | 12 +- utils/rulesVersion.js | 3 +- 25 files changed, 515 insertions(+), 478 deletions(-) create mode 100644 api/helpers/game-states/is-top-jack.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 50598698b..04b572dc4 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 @@ -85,12 +85,35 @@ module.exports = { const twosAndNines = playerHand.filter((card) => [ 2, 9 ].includes(card.rank)); for (let twoOrNine of twosAndNines) { for (let potentialTarget of opponentFaceCards) { - res.push({ moveType, playedBy, cardId: twoOrNine.id, targetId: potentialTarget.id, targetType: 'faceCard' }); + res.push({ + moveType, + playedBy, + cardId: twoOrNine.id, + targetId: potentialTarget.id, + targetType: TargetType.faceCard, + }); } for (let pointCard of opponentPoints) { + // Only nines can target the point card itself + if (twoOrNine.rank === 9) { + res.push({ + moveType, + playedBy, + cardId: twoOrNine.id, + targetId: pointCard.id, + targetType: TargetType.point, + }); + } + if (pointCard.attachments.length) { - res.push({ moveType, playedBy, cardId: twoOrNine.id, targetId: pointCard.attachments.at(-1).id }); + res.push({ + moveType, + playedBy, + cardId: twoOrNine.id, + targetId: pointCard.attachments.at(-1).id, + targetType: TargetType.jack, + }); } } } @@ -224,6 +247,17 @@ module.exports = { } for (let opponentPoint of opponentPoints) { + // Only nines can target the point card itself + if (targetedOneOff.rank === 9) { + res.push({ + moveType, + playedBy, + cardId: targetedOneOff.id, + targetId: opponentPoint.id, + targetType: TargetType.point, + }); + } + if (opponentPoint.attachments.length) { res.push({ moveType, diff --git a/api/helpers/game-states/get-log.js b/api/helpers/game-states/get-log.js index 38d6ec6bd..5bd5134a9 100644 --- a/api/helpers/game-states/get-log.js +++ b/api/helpers/game-states/get-log.js @@ -108,7 +108,7 @@ module.exports = { )} and ${getFullCardName(deck[1])}.`; case 9: - return `The ${resolvedCardName} one-off resolves, returning the ${targetCardName} to ${player}'s hand. It cannot be played next turn.`; + return `The ${resolvedCardName} one-off resolves, putting the ${targetCardName} on top of the deck.`; } break; diff --git a/api/helpers/game-states/is-top-jack.js b/api/helpers/game-states/is-top-jack.js new file mode 100644 index 000000000..2893ac757 --- /dev/null +++ b/api/helpers/game-states/is-top-jack.js @@ -0,0 +1,32 @@ +module.exports = { + friendlyName: 'Is top jack', + + description: 'Determines whether the specified jack is the top of the stack attached to the point card it is stealing', + + extendedDescription: + 'Only the top jack of a stack may be targeted by a nine, since removing a buried jack would leave the remaining stack in an order that never occurred.', + + inputs: { + targetId: { + type: 'string', + description: 'String ID of the jack to check', + required: true, + example: 'JS', // Jack of Spades + }, + /** + * @param { Player } player - the player whose points should contain the jack's host + * @param { Card[] } player.points - the player's point cards -- can contain attachments + */ + player: { + type: 'ref', + descriptions: 'Player object from the current game state', + required: true, + }, + }, + sync: true, + fn: ({ targetId, player }, exits) => { + const host = player.points.find(({ attachments }) => attachments.some(({ id }) => id === targetId)); + + return exits.success(host?.attachments.at(-1).id === targetId); + }, +}; diff --git a/api/helpers/game-states/moves/one-off/validate.js b/api/helpers/game-states/moves/one-off/validate.js index a7dd5a129..02b057be1 100644 --- a/api/helpers/game-states/moves/one-off/validate.js +++ b/api/helpers/game-states/moves/one-off/validate.js @@ -86,6 +86,15 @@ module.exports = { throw new BadRequestError('Twos can only target royals or glasses'); } + // Nines put the target on top of the deck, so a buried jack is never a legal target + if ( + playedCard.rank === 9 && + requestedMove.targetType === 'jack' && + !sails.helpers.gameStates.isTopJack(requestedMove.targetId, opponent) + ) { + throw new BadRequestError('game.snackbar.oneOffs.nine.onlyTopJack'); + } + const queenCount = opponent.faceCards.filter((faceCard) => faceCard.rank === 12).length; // Legal if not blocked by opponent's queen(s) diff --git a/api/helpers/game-states/moves/resolve/execute.js b/api/helpers/game-states/moves/resolve/execute.js index 92d7b4643..728b0eedb 100644 --- a/api/helpers/game-states/moves/resolve/execute.js +++ b/api/helpers/game-states/moves/resolve/execute.js @@ -61,6 +61,9 @@ module.exports = { result.moveType = MoveType.FIZZLE; result.scrap.push(result.oneOff); result.oneOff = null; + // Nothing is pending anymore, so the target slots have to clear too + result.oneOffTarget = null; + result.oneOffTargetType = null; result.phase = playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN; result.turn = playerMustDiscard ? result.turn : result.turn + 1; return exits.success(result); diff --git a/api/helpers/game-states/moves/resolve/nine.js b/api/helpers/game-states/moves/resolve/nine.js index fd5d90412..74168779b 100644 --- a/api/helpers/game-states/moves/resolve/nine.js +++ b/api/helpers/game-states/moves/resolve/nine.js @@ -3,12 +3,12 @@ const TargetType = require('../../../../../utils/TargetType.json'); module.exports = { friendlyName: 'Resolve Nine One-Off', - description: 'Returns new GameState resulting from resolving an nine, which returns target card to opponents hand', + description: 'Returns new GameState resulting from resolving a nine, which puts the target card on top of the deck', inputs: { currentState: { type: 'ref', - description: 'The latest GameState before the nine returns target card to opponents hand', + description: 'The latest GameState before the nine puts the target card on top of the deck', required: true, }, playedBy: { @@ -23,36 +23,47 @@ module.exports = { // the user who played the one off is the opposite of the one who is resolving it. const opponent = playedBy ? result.p1 : result.p0; const player = playedBy ? result.p0 : result.p1; - + + // Cards go onto the deck face down, and the deck is drawn from the front + const topDeck = (card) => result.deck.unshift({ ...card, attachments: [] }); + if ([ TargetType.point, TargetType.faceCard ].includes(result.oneOffTargetType)) { const targetIndex = opponent[`${result.oneOffTargetType}s`].findIndex(({ id }) => id === result.oneOffTarget.id); + // Sanity check: targets can't disappear mid-resolution, but no-op rather than splice(-1) + if (targetIndex < 0) { + return exits.success(result); + } const [ targetCard ] = opponent[`${result.oneOffTargetType}s`].splice(targetIndex, 1); - - opponent.hand.push({ ...targetCard, isFrozen: true, attachments: [] }); - + // scrap all attachments result.scrap.push(...targetCard.attachments); + topDeck(targetCard); + return exits.success(result); - } - + } + // TargetType = jack // Find card that the jack is attached to const targetIndex = opponent.points.findIndex(({ attachments }) => attachments.some(({ id }) => id === result.oneOffTarget.id)); + if (targetIndex < 0) { + return exits.success(result); + } const [ targetCard ] = opponent.points.splice(targetIndex, 1); - - // remove last attachment - targetCard.attachments.pop(); + + // Remove the targeted jack, which the validator guarantees is the top of the stack + const jackIndex = targetCard.attachments.findIndex(({ id }) => id === result.oneOffTarget.id); + const [ jack ] = targetCard.attachments.splice(jackIndex, 1); // Card the jack was stealing goes back to other player player.points.push( { ...targetCard, }); - - // Push jack back to opponents hand - opponent.hand.push({ ...result.oneOffTarget, isFrozen: true, attachments: [] }); + + // Put the jack on top of the deck + topDeck(jack); return exits.success(result); }, }; diff --git a/api/helpers/game-states/moves/seven-one-off/validate.js b/api/helpers/game-states/moves/seven-one-off/validate.js index fef0899b9..5b4e15c91 100644 --- a/api/helpers/game-states/moves/seven-one-off/validate.js +++ b/api/helpers/game-states/moves/seven-one-off/validate.js @@ -76,6 +76,15 @@ module.exports = { throw new BadRequestError('Twos can only target royals or glasses'); } + // Nines put the target on top of the deck, so a buried jack is never a legal target + if ( + playedCard.rank === 9 && + requestedMove.targetType === 'jack' && + !sails.helpers.gameStates.isTopJack(requestedMove.targetId, opponent) + ) { + throw new BadRequestError('game.snackbar.oneOffs.nine.onlyTopJack'); + } + const queenCount = opponent.faceCards.filter((faceCard) => faceCard.rank === 12).length; // Legal if not blocked by opponent's queen(s) diff --git a/api/helpers/game-states/unpack-gamestate.js b/api/helpers/game-states/unpack-gamestate.js index ec4288a0a..f92d5ea21 100644 --- a/api/helpers/game-states/unpack-gamestate.js +++ b/api/helpers/game-states/unpack-gamestate.js @@ -39,14 +39,7 @@ module.exports = { if (value) { const { convertStrToCard } = sails.helpers.gameStates; convertedData[attribute] = Array.isArray(value) - ? value.map((cardStr) => { - // Is the card frozen - const freezeCard = - !!gameStateRow.resolved && - gameStateRow.resolved[0] === '9' && - gameStateRow.targetCard === cardStr.substring(0, 3); - return convertStrToCard(cardStr, freezeCard); - }) + ? value.map((cardStr) => convertStrToCard(cardStr)) : convertStrToCard(value); } }); diff --git a/docs/game-rules.md b/docs/game-rules.md index 948195e4d..ca77309b3 100644 --- a/docs/game-rules.md +++ b/docs/game-rules.md @@ -48,7 +48,7 @@ On your turn you must make exactly one of the following actions below. - **Five**: Discard one card then draw up to three cards from the **Deck** up to the 8 card hand limit. - **Six**: Scrap all **Royals and Glasses Eights**, including your own. - **Seven**: Reveal the top two cards from the **Deck** and choose one of them to play immediately. Play this card however you would like. The other card is placed back on top of the deck. -- **Nine**: Return an opponent's card on the field to their hand. It can't be played next turn. +- **Nine**: Put an opponent's card on the field on top of the deck. Only the top Jack of a stack may be targeted. ## Royals & Glasses Eights diff --git a/docs/game-state-api.md b/docs/game-state-api.md index 2705c15ed..26cb0efe5 100644 --- a/docs/game-state-api.md +++ b/docs/game-state-api.md @@ -155,7 +155,7 @@ Represents a single card, in its object format. * id: `String` (same format as the gameStateRow cards e.g. ‘AS’ for Ace of Spades) * suit: `0 | 1 | 2 | 3` * rank: `[1- 13]` -* isFrozen: `Boolean` \- whether the card is currently frozen due to a 9 and can’t be played this turn +* isFrozen: `Boolean` \- whether the card is currently frozen and can’t be played this turn. No effect currently freezes a card; retained pending the outcome of the topdeck-nines beta ## Player diff --git a/src/routes/game/GameView.vue b/src/routes/game/GameView.vue index 879029e4e..b7a84ac54 100644 --- a/src/routes/game/GameView.vue +++ b/src/routes/game/GameView.vue @@ -466,8 +466,6 @@ export default { targeting: false, targetingMoveName: null, targetingMoveDisplayName: null, - targetType: null, - nineTargetIndex: null, showFourDialog: false, topCardIsSelected: false, secondCardIsSelected: false, @@ -553,18 +551,10 @@ export default { case 2: case 6: return Transitions.SLIDE_UP; - // For nines, transition direction depends on target type + // Nines put the target on the deck; the only point card entering here is a + // jacked card whose control reverts when its jack is topdecked case 9: - switch (this.gameStore.lastEventTargetType) { - // Nine on jack causes points to swap control - case 'jack': - return Transitions.SLIDE_UP; - // Everything else expect cards to move back to hand - case 'point': - case 'faceCard': - default: - return Transitions.SLIDE_DOWN; - } + return Transitions.SLIDE_UP; default: return Transitions.SLIDE_DOWN_LEFT; } @@ -578,15 +568,6 @@ export default { } }, playerFaceCardsTransition() { - // If a face card is bounced by a nine, slide down to player hand - if ( - this.gameStore.lastEventChange === 'resolve' && - this.gameStore.lastEventOneOffRank === 9 && - this.gameStore.lastEventTargetType === 'faceCard' - ) { - return Transitions.SLIDE_DOWN; - } - // Playing from the deck if (this.gameStore.lastEventChange === 'sevenFaceCard') { return this.$vuetify.display.xs ? Transitions.SLIDE_DOWN : Transitions.ENTER_FROM_UPPER_LEFT; @@ -610,16 +591,10 @@ export default { case 2: case 6: return Transitions.SLIDE_DOWN; - // Nine transitions depend on the target type + // Nines move the target off the board entirely (to the deck), or hand a + // jacked point card back down to the player case 9: - switch (this.gameStore.lastEventTargetType) { - // Nine on a jack switches point card control - case 'jack': - return Transitions.SLIDE_DOWN; - // Everything else returns cards to hand - default: - return Transitions.SLIDE_UP; - } + return Transitions.SLIDE_DOWN; default: return Transitions.SLIDE_UP_DOWN; } @@ -629,14 +604,6 @@ export default { } }, opponentFaceCardsTransition() { - // If a face card is bounced by a nine, slide up to opponent's hand - if ( - this.gameStore.lastEventChange === 'resolve' && - this.gameStore.lastEventOneOffRank === 9 && - this.gameStore.lastEventTargetType === 'faceCard' - ) { - return Transitions.SLIDE_UP; - } // Playing from the deck if (this.gameStore.lastEventChange === 'sevenFaceCard') { return this.$vuetify.display.xs ? Transitions.SLIDE_DOWN : Transitions.ENTER_FROM_LEFT; @@ -712,18 +679,6 @@ export default { return []; } }, - nineTarget() { - switch (this.targetType) { - case 'point': - return this.nineTargetIndex !== null ? this.gameStore.opponent.points[this.nineTargetIndex] : null; - case 'faceCard': - return this.nineTargetIndex !== null - ? this.gameStore.opponent.faceCards[this.nineTargetIndex] - : null; - default: - return null; - } - }, // Sevens playingFromDeck() { return this.gameStore.playingFromDeck; @@ -787,8 +742,6 @@ export default { this.snackbarStore.alert(this.t(messageKey), 'base-dark'); }, clearOverlays() { - this.nineTargetIndex = null; - this.targetType = null; this.targeting = false; }, clearSelection() { diff --git a/src/routes/home/components/announcementDialog/data/announcementData.js b/src/routes/home/components/announcementDialog/data/announcementData.js index c3cbd96f6..45e25cd9d 100644 --- a/src/routes/home/components/announcementDialog/data/announcementData.js +++ b/src/routes/home/components/announcementDialog/data/announcementData.js @@ -1,23 +1,23 @@ // import { Card } from '../../../../../../tests/e2e/fixtures/cards'; export const announcementData = { - id: 'diamonds2026LiveAnnouncement', + id: 'topdeckNines2026Announcement', activatorText: 'announcement.activatorText', title: 'announcement.title', - displayCards: [], - imgSrc: '/img/announcement/diamonds_2026_cuttle_season_championship.svg', - imgHref: 'https://challonge.com/diamonds_2026_cuttle_season_championship', - startTime: '2026-08-30T12:00:00-04:00', - endTime: '2026-08-30T17:00:00-04:00', + displayCards: [ + { suit: 3, rank: 9 }, + { suit: 2, rank: 9 }, + ], + startTime: '2026-09-16', + endTime: '2027-01-05', announcementText: [ { heading: 'announcement.heading', - paragraph: [ - { text: 'announcement.paragraph' }, - { text: 'announcement.twitchLink', url: 'https://www.twitch.tv/cuttle_cards' }, - { text: 'announcement.linkSeparator' }, - { text: 'announcement.youtubeLink', url: 'https://www.youtube.com/live/mMYULGUXrfs' }, - ], + paragraph: [ { text: 'announcement.paragraph' } ], + }, + { + heading: 'announcement.heading2', + paragraph: [ { text: 'announcement.paragraph2' } ], }, ], }; diff --git a/src/stores/game.js b/src/stores/game.js index 1a6ef097c..71b0151c7 100644 --- a/src/stores/game.js +++ b/src/stores/game.js @@ -104,7 +104,6 @@ export const useGameStore = defineStore('game', () => { // Last Event const lastEventChange = ref(null); const lastEventOneOffRank = ref(null); - const lastEventTargetType = ref(null); const lastEventPlayedBy = ref(null); // GameOver const gameIsOver = ref(false); @@ -249,7 +248,6 @@ export const useGameStore = defineStore('game', () => { function updateGame(newGame) { lastEventChange.value = newGame.lastEvent?.change ?? null; lastEventOneOffRank.value = newGame.lastEvent?.oneOff?.rank ?? null; - lastEventTargetType.value = newGame.lastEvent?.oneOffTargetType ?? null; const lastEventPlayer = newGame.lastEvent?.pNum; lastEventPlayerChoosing.value = typeof lastEventPlayer === 'number' && myPNum.value !== null ? lastEventPlayer === myPNum.value : null; @@ -310,7 +308,6 @@ export const useGameStore = defineStore('game', () => { lastEventPlayerChoosing.value = false; lastEventChange.value = null; lastEventOneOffRank.value = null; - lastEventTargetType.value = null; lastEventPlayedBy.value = null; lastEventThreeTarget.value = null; gameIsOver.value = false; @@ -737,7 +734,6 @@ export const useGameStore = defineStore('game', () => { lastEventThreeTarget, lastEventChange, lastEventOneOffRank, - lastEventTargetType, lastEventPlayedBy, gameIsOver, winnerPNum, diff --git a/src/translations/de.json b/src/translations/de.json index 4303b53f3..af682fbef 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -241,7 +241,7 @@ "6": "Alle Könige und Gläser Acht abwerfen", "7": "Spiele eine der obersten zwei Karten des Decks und lege die andere zurück (beide sind aufgedeckt)", "8": "Dein Gegner spielt mit offener Hand (seine Karten sind für dich sichtbar)", - "9": "Gib die Zielkarte zurück in die Hand des Besitzers. Sie kann sie im nächsten Zug nicht spielen", + "9": "Lege die Zielkarte oben auf den Nachziehstapel", "10": "Kein Effekt", "11": "Spiele auf eine Ziel-Punktkarte, um sie zu stehlen", "12": "Deine anderen Karten können nur durch Abwerfaktionen angegriffen werden", @@ -362,6 +362,9 @@ "wrongPhase": "Du kannst jetzt nicht vom Deck spielen", "mustBeDoubleJacks": "Du kannst keine Karten von einer Sieben abwerfen, es sei denn, beide Karten sind Buben", "jacksMustBeUnplayable": "Du kannst nicht abwerfen, weil die Buben gespielt werden können" + }, + "nine": { + "onlyTopJack": "Du kannst nur den obersten Buben eines Stapels als Ziel wählen" } } } @@ -461,7 +464,7 @@ "seven": "Sieben", "sevenDescription": "Zeige die obersten zwei Karten des Stapels beiden Spielern und wähle eine, die du nach Belieben spielst.", "nine": "Neun", - "nineDescription": "Gib eine Karte vom Spielfeld deines Gegners auf dessen Hand zurück. Sie darf in der nächsten Runde nicht gespielt werden.", + "nineDescription": "Lege eine Karte vom Spielfeld deines Gegners oben auf den Nachziehstapel.", "ten": "Zehn", "tenDescription": "Kein Effekt." }, @@ -490,7 +493,7 @@ "rule2": "Mische zwei Joker in das Deck. Joker zählen als Royals mit dem Effekt 'Stehle eine Royal-Karte'. Ähnlich wie Buben können sie eine Royal von einem Spieler zu einem anderen transferieren.", "rule3": "Das Handkartenlimit ist auf 7 (statt 8) reduziert.", "rule4": "Buben können gespielt werden, um eine Punktekarte von einem beliebigen Spieler zu einem anderen zu übertragen. Du kannst daher Punkte von dir selbst (oder anderen) 'stehlen', um sie deinem Partner zu geben und möglicherweise mit deren Punkten in deinem Zug zu gewinnen (ihr Ziel wird weiterhin durch die Anzahl ihrer eigenen Könige auf dem Spielfeld bestimmt).", - "rule5": "Neuner können für einen alternativen ähnlichen Effekt gespielt werden. Immer wenn ein Gegner eine Royal spielt, kannst du sofort einen Neuner als Antwort spielen, um diese Karte sofort auf die Hand des Besitzers zurückzugeben und ihren Effekt zu verhindern. Dies kann außerhalb des Zuges erfolgen, kostet keinen Zug, verhindert jedoch nicht, dass der Besitzer die Karte in seinem nächsten Zug erneut spielt. Alternativ kannst du weiterhin einen Neuner für seinen ursprünglichen Einmal-Effekt in deinem Zug spielen (dein Zug wird dafür genutzt), um eine Karte auf die Hand ihres Besitzers zurückzugeben und zu verhindern, dass sie in der nächsten Runde gespielt wird." + "rule5": "Neuner können für einen alternativen ähnlichen Effekt gespielt werden. Immer wenn ein Gegner eine Royal spielt, kannst du sofort einen Neuner als Antwort spielen, um diese Karte sofort auf die Hand des Besitzers zurückzugeben und ihren Effekt zu verhindern. Dies kann außerhalb des Zuges erfolgen, kostet keinen Zug, verhindert jedoch nicht, dass der Besitzer die Karte in seinem nächsten Zug erneut spielt. Alternativ kannst du weiterhin einen Neuner für seinen ursprünglichen Einmal-Effekt in deinem Zug spielen (dein Zug wird dafür genutzt), um eine Karte vom Spielfeld deines Gegners oben auf den Nachziehstapel zu legen." }, "faq": { "title": "FAQ", @@ -586,13 +589,12 @@ } }, "announcement": { - "activatorText": "Jetzt live: Diamonds 2026 Saisonmeisterschaft!", - "title": "Diamonds 2026 Meisterschaft", - "heading": "Jetzt live: Cuttle-Saisonmeisterschaft", - "paragraph": "Die Diamonds 2026 Cuttle-Saisonmeisterschaft läuft gerade live! Die 8 besten Spielerinnen und Spieler der vergangenen Ranglistensaison kämpfen im Doppel-K.-o.-Modus um den Titel der Diamonds 2026 Cuttle-Saisonmeisterschaft. Verfolge alles live auf", - "twitchLink": "Twitch", - "linkSeparator": "oder", - "youtubeLink": "YouTube" + "activatorText": "Neuner werden gestärkt!", + "title": "Regel-Beta: Neuner legen obenauf", + "heading": "Neuner legen Karten jetzt oben auf den Nachziehstapel", + "paragraph": "Ein Neuner ist eine der höchsten Karten im Spiel, doch sein Einmal-Effekt war schon immer der speziellste. Statt eine Karte auf die Hand ihres Besitzers zurückzugeben, legt ein Neuner jetzt eine Karte vom Spielfeld deines Gegners oben auf den Nachziehstapel — eine Punktekarte, eine Royal oder den obersten Buben eines Stapels. Ihr nächster Zug bringt ihnen die eigene Karte zurück, der Neuner kostet sie also einen ganzen Zug. Nichts wird mehr eingefroren, und Damen schützen weiterhin wie bisher.", + "heading2": "Test während der Pik-Saison", + "paragraph2": "Das ist eine Beta. Wir lassen sie die gesamte Pik-Saison über laufen, damit alle sie in echten Partien ausprobieren können, und stellen sie dann gegen Ende des Jahres in einer Community-Umfrage zur Abstimmung." }, "profile": { "myGames": "Meine Spiele", diff --git a/src/translations/en.json b/src/translations/en.json index bca7781dc..353779aa4 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -241,7 +241,7 @@ "6": "Scrap all Royals and Glasses eights", "7": "Play one of the top two cards of the deck and put the other back (both are revealed)", "8": "Your opponent plays with an open hand (their cards are revealed to you)", - "9": "Return target card to its controller's hand. They can't play it next turn", + "9": "Put target card on top of the deck", "10": "No effect", "11": "Play on top of target point card to steal it", "12": "Your other cards may only be targeted by scuttles", @@ -363,6 +363,9 @@ "wrongPhase": "You can't play from the deck right now", "mustBeDoubleJacks": "You can't discard from a seven unless both cards are jacks", "jacksMustBeUnplayable": "You can't discard because the jacks can be played" + }, + "nine": { + "onlyTopJack": "You can only target the top Jack of a stack" } } } @@ -462,7 +465,7 @@ "seven": "Seven", "sevenDescription": "Reveal the top two cards of the deck to both players and choose one to play however you choose.", "nine": "Nine", - "nineDescription": "Return a card from your opponent's field to their hand. They cannot play it next turn.", + "nineDescription": "Put a card from your opponent's field on top of the deck.", "ten": "Ten", "tenDescription": "No effect." }, @@ -491,7 +494,7 @@ "rule2": "Shuffle two Jokers into the deck. Jokers count as Royals with the effect \"Steal target Royal\". Similar to Jacks, they may transfer control of a Royal from any player to any other player.", "rule3": "The hand limit is reduced to 7 (from 8).", "rule4": "Jacks may be played to transfer control of any point card to any other player. You may therefore \"steal\" points from yourself (or anyone else) in order to give them to your partner, potentially winning on your turn with their points (their goal is still determined by the count of their own Kings on the field).", - "rule5": "Nines can be played for an alternative, similar effect. Whenever an opponent plays a Royal, you may immediately play a Nine in response to return that card to its owner's hand immediately, preventing its effect from taking place. Doing so may be done out of turn and does not take your turn, but does not prevent the target's owner from playing the card again on their next turn. Alternatively, you may still play a Nine for its original one-off effect on your turn (taking your turn to do so) to return a card to its controller's hand and prevent them from playing that card on their next turn." + "rule5": "Nines can be played for an alternative, similar effect. Whenever an opponent plays a Royal, you may immediately play a Nine in response to return that card to its owner's hand immediately, preventing its effect from taking place. Doing so may be done out of turn and does not take your turn, but does not prevent the target's owner from playing the card again on their next turn. Alternatively, you may still play a Nine for its original one-off effect on your turn (taking your turn to do so) to put a card from your opponent's field on top of the deck." }, "faq": { "title": "FAQ", @@ -587,13 +590,12 @@ } }, "announcement": { - "activatorText": "Live now: Diamonds 2026 Season Championship!", - "title": "Diamonds 2026 Championship", - "heading": "Live Now: Cuttle Season Championship", - "paragraph": "The Diamonds 2026 Cuttle Season Championship is live right now! The top 8 players from the past season of ranked play are competing in a double elimination showdown for the title of Diamonds 2026 Cuttle Season Champion. Watch it live on", - "twitchLink": "Twitch", - "linkSeparator": "or", - "youtubeLink": "YouTube" + "activatorText": "Nines are getting a buff!", + "title": "Rules Beta: Topdeck Nines", + "heading": "Nines now put cards on top of the deck", + "paragraph": "A Nine is one of the highest cards in the deck, but its one-off has always been the most niche. Now, instead of returning a card to its controller's hand, a Nine one-off puts a card from your opponent's board on top of the deck — a point card, a Royal, or the top Jack of a stack. Their next draw is their own card coming back, so the Nine costs them a whole turn. Nothing is frozen any more, and Queens still protect as before.", + "heading2": "Testing through the Spades season", + "paragraph2": "This is a beta. We'll run it for the whole Spades season so everyone can feel it out in real games, then put it to a community poll toward the end of the year to decide whether it stays." }, "profile": { "myGames": "My Games", diff --git a/src/translations/es.json b/src/translations/es.json index 569d679d2..86fb74ef5 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -241,7 +241,7 @@ "6": "Descarta todas las Figuras y Gafas Ocho en juego", "7": "Revela las dos primeras cartas del mazo de robo y elige una de ellas para jugarla como quieras", "8": "Puedes ver las cartas de la mano de tu rival (hasta que esta carta se descarte)", - "9": "Devuelve la carta objetivo a la mano de su controlador. No puede volver a jugarla en su próximo turno.", + "9": "Pon la carta objetivo encima del mazo", "10": "Sin efecto", "11": "Juega sobre una carta de puntos objetivo para robarla", "12": "Tus otras cartas no pueden ser objetivo de habilidades Únicas (pero sí pueden ser hundidas por cartas de puntos más altas)", @@ -362,6 +362,9 @@ "wrongPhase": "No puedes jugar desde el mazo en este momento", "mustBeDoubleJacks": "No puedes descartar desde un siete a menos que ambas cartas sean jotas", "jacksMustBeUnplayable": "No puedes descartar porque las jotas se pueden jugar" + }, + "nine": { + "onlyTopJack": "Solo puedes seleccionar la Jota superior de una pila" } } } @@ -461,7 +464,7 @@ "seven": "Siete", "sevenDescription": "Revela las dos primeras cartas del mazo a ambos jugadores y elige una para jugar como desees.", "nine": "Nueve", - "nineDescription": "Devuelve la carta objetivo a la mano de su controlador. No puede volver a jugarla en su próximo turno", + "nineDescription": "Pon una carta del campo de tu rival encima del mazo.", "ten": "Diez", "tenDescription": "Sin efecto." }, @@ -490,7 +493,7 @@ "rule2": "Baraja dos comodines (Jokers) en el mazo. Los comodines cuentan como Figuras con el efecto \"Roba una Figura objetivo\", similar a la Sota. Al igual que las Sotas, pueden transferir el control de una Figura de cualquier jugador a cualquier otro jugador.", "rule3": "El límite de cartas en la mano se reduce a 7 (de 8).", "rule4": "Se pueden jugar Sotas para transferir el control de cualquier carta de puntos a cualquier otro jugador. Por lo tanto, puedes \"robarte\" puntos a ti mismo (o a cualquier otra persona) para dárselos a tu compañero, potencialmente ganando en tu turno con sus puntos (su objetivo aún está determinado por la cantidad de Reyes en su propio campo).", - "rule5": "Se pueden jugar Nueves para obtener un efecto alternativo similar: siempre que un rival juegue una Figura, puedes jugar inmediatamente un Nueve en respuesta para devolver esa carta a la mano de su propietario inmediatamente, evitando que se produzca su efecto. Hacerlo puede hacerse fuera de turno y no gasta tu turno, pero no impide que el propietario del objetivo vuelva a jugar la carta en su siguiente turno. Alternativamente, aún puedes jugar un Nueve para obtener su efecto único original en tu turno (gastando tu turno para hacerlo), para devolver una carta a la mano de su controlador y evitar que juegue esa carta en su próximo turno." + "rule5": "Se pueden jugar Nueves para obtener un efecto alternativo similar: siempre que un rival juegue una Figura, puedes jugar inmediatamente un Nueve en respuesta para devolver esa carta a la mano de su propietario inmediatamente, evitando que se produzca su efecto. Hacerlo puede hacerse fuera de turno y no gasta tu turno, pero no impide que el propietario del objetivo vuelva a jugar la carta en su siguiente turno. Alternativamente, aún puedes jugar un Nueve para obtener su efecto único original en tu turno (gastando tu turno para hacerlo), para poner una carta del campo de tu rival encima del mazo." }, "faq": { "title": "FAQ", @@ -586,13 +589,12 @@ } }, "announcement": { - "activatorText": "¡En directo ahora: Campeonato de la temporada Diamonds 2026!", - "title": "Campeonato Diamonds 2026", - "heading": "En directo ahora: Campeonato de temporada de Cuttle", - "paragraph": "¡El Campeonato de la Temporada Diamonds 2026 de Cuttle está en directo ahora mismo! Los 8 mejores jugadores de la última temporada de partidas clasificatorias se enfrentan en un torneo de doble eliminación por el título de Campeón de la Temporada Diamonds 2026 de Cuttle. Míralo en directo en", - "twitchLink": "Twitch", - "linkSeparator": "o", - "youtubeLink": "YouTube" + "activatorText": "¡Los Nueves reciben una mejora!", + "title": "Beta de reglas: Nueves al mazo", + "heading": "Los Nueves ahora ponen cartas encima del mazo", + "paragraph": "El Nueve es una de las cartas más altas de la baraja, pero su efecto único siempre ha sido el más específico. Ahora, en lugar de devolver una carta a la mano de su controlador, un Nueve pone una carta del campo de tu rival encima del mazo: una carta de puntos, una Figura o la Jota superior de una pila. Su siguiente robo será su propia carta, así que el Nueve les cuesta un turno entero. Ya no se congela nada, y las Damas siguen protegiendo igual que antes.", + "heading2": "En pruebas durante la temporada de Picas", + "paragraph2": "Esto es una beta. La mantendremos durante toda la temporada de Picas para que todo el mundo pueda probarla en partidas reales y después la someteremos a una votación de la comunidad hacia final de año para decidir si se queda." }, "profile": { "myGames": "Mis juegos", diff --git a/src/translations/fr.json b/src/translations/fr.json index 6dfd8057d..5c7811a05 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -241,7 +241,7 @@ "6": "Élimine toutes les figures et les huit en lunettes.", "7": "Dévoilez deux cartes de la pioche et jouez en une.", "8": "Révèle la main de votre adversaire.", - "9": "L'adversaire reprend en main la carte ciblée qui est gelée pour un tour.", + "9": "Place la carte ciblée sur le dessus de la pioche", "10": "Aucune tactique.", "11": "Cible et vole une carte à points.", "12": "Protège vos cartes à points des attaques tactiques", @@ -362,6 +362,9 @@ "wrongPhase": "Vous ne pouvez pas jouer depuis le paquet pour le moment", "mustBeDoubleJacks": "Vous ne pouvez pas défausser depuis un sept sauf si les deux cartes sont des valets", "jacksMustBeUnplayable": "Vous ne pouvez pas défausser parce que les valets peuvent être joués" + }, + "nine": { + "onlyTopJack": "Vous ne pouvez cibler que le Valet du dessus d'une pile" } } } @@ -461,7 +464,7 @@ "seven": "Sept", "sevenDescription": "Révélez les deux premières cartes du deck aux deux joueurs et choisissez-en une à jouer comme vous le souhaitez.", "nine": "Neuf", - "nineDescription": "Ciblez une carte active de votre adversaire pour la renvoyer dans sa main. Il pne pourra pas la jouer à son prochain tour.", + "nineDescription": "Placez une carte du terrain de votre adversaire sur le dessus de la pioche.", "ten": "Dix", "tenDescription": "Aucun effet." }, @@ -490,7 +493,7 @@ "rule2": "Se joue avec un paquet de 54 cartes comprenant les deux jokers qui ont la capacité de transférer un carte Figure d'un joueur vers n'importe quel joueur.", "rule3": "Votre main est limitée à 7 cartes (au lien de 8).", "rule4": "Les valets peuvent être joués pour transférer n'importe quelle carte à points à n'importe quel autre joueur. Par conséquent, vous pouvez 'voler' des points à vous-même (ou à n'importe qui d'autre) pour les donner à votre partenaire, gagnant potentiellement à votre tour avec leurs points (leur objectif est toujours déterminé par le nombre de rois sur le terrain).", - "rule5": "Le Neuf a une capacité supplémentaire. Il peut être joué pour contrer une carte figure jouée par un adversaire. Ainsi joué, le Neuf n'empêche pas cet adversaire de rejouer la même carte à son prochain tour. Si vous jouez un Neuf à votre tour (son effet habituel est appliqué. L'adversaire reprend en main la carte ciblée et n'a pas le droit de la jouer à son prochain tour." + "rule5": "Le Neuf a une capacité supplémentaire. Il peut être joué pour contrer une carte figure jouée par un adversaire. Ainsi joué, le Neuf n'empêche pas cet adversaire de rejouer la même carte à son prochain tour. Si vous jouez un Neuf à votre tour, son effet habituel est appliqué : la carte ciblée est placée sur le dessus de la pioche." }, "faq": { "title": "FAQ", @@ -586,13 +589,12 @@ } }, "announcement": { - "activatorText": "En direct maintenant : Championnat de saison Diamonds 2026 !", - "title": "Championnat Diamonds 2026", - "heading": "En direct maintenant : Championnat de saison Cuttle", - "paragraph": "Le Championnat de saison Diamonds 2026 de Cuttle est en direct en ce moment ! Les 8 meilleurs joueurs de la dernière saison classée s'affrontent en double élimination pour le titre de Champion de la saison Diamonds 2026 de Cuttle. À suivre en direct sur", - "twitchLink": "Twitch", - "linkSeparator": "ou", - "youtubeLink": "YouTube" + "activatorText": "Les Neufs sont renforcés !", + "title": "Bêta des règles : Neufs sur la pioche", + "heading": "Les Neufs placent désormais les cartes sur la pioche", + "paragraph": "Le Neuf est l'une des plus fortes cartes du jeu, mais son effet unique a toujours été le plus limité. Désormais, au lieu de renvoyer une carte dans la main de son contrôleur, un Neuf place une carte du terrain de votre adversaire sur le dessus de la pioche : une carte de points, une figure ou le Valet du dessus d'une pile. Sa prochaine pioche lui rendra sa propre carte, le Neuf lui coûte donc un tour entier. Plus rien n'est gelé, et les Dames protègent toujours comme avant.", + "heading2": "Test pendant la saison des Piques", + "paragraph2": "Il s'agit d'une bêta. Nous la laisserons en place pendant toute la saison des Piques afin que chacun puisse l'essayer en partie réelle, puis nous la soumettrons à un vote de la communauté vers la fin de l'année pour décider si elle reste." }, "profile": { "myGames": "Mes jeux", diff --git a/src/translations/ukr.json b/src/translations/ukr.json index 3d3548a2d..9db13aa83 100644 --- a/src/translations/ukr.json +++ b/src/translations/ukr.json @@ -241,7 +241,7 @@ "6": "Знищити всі королівські карти та восьмірки окулярів", "7": "Зіграти одну з перших двох карт з колоди та повернути іншу назад (обидві відкриті)", "8": "Ваш суперник грає з відкритою рукою (його карти відкриті для вас)", - "9": "Повернути вибрану карту в руку її власника. Вони не можуть зіграти її наступного ходу", + "9": "Покласти вибрану карту зверху колоди", "10": "Без ефекту", "11": "Зіграти поверх карти з очками, щоб вкрасти її", "12": "Ваші інші карти можуть бути вибрані лише для знищення", @@ -362,6 +362,9 @@ "wrongPhase": "Ви не можете грати з колоди зараз", "mustBeDoubleJacks": "Не можна відкидати з сімки, якщо обидві картки не є валетами", "jacksMustBeUnplayable": "Не можна відкидати, тому що валети можуть бути зіграні" + }, + "nine": { + "onlyTopJack": "Ви можете вибрати лише верхнього Валета у стосі" } } } @@ -461,7 +464,7 @@ "seven": "Сім", "sevenDescription": "розкрийте та покажіть усім дві верхні карти з колоди, а потім оберіть одну та зіграйте нею тим способом, який ви оберете.", "nine": "Дев'ять", - "nineDescription": "поверніть будь-яку карту з поля вашого суперника йому назад у руку. Він не може зіграти нею наступного ходу.", + "nineDescription": "покладіть будь-яку карту з поля вашого суперника зверху колоди.", "ten": "Десять", "tenDescription": "не має ефектів." }, @@ -490,7 +493,7 @@ "rule2": "До колоди додають двоє джокерів. Джокери рахуються як королі з ефектом \"Викрасти цільового короля\". Як і валети, вони можуть передавати контроль над королем від одного гравця до іншого.", "rule3": "Ліміт карт у руці зменшено до 7 (з 8).", "rule4": "Валети можна грати, щоб передати контроль над будь-якою карткою балів іншому гравцеві. Тому ви можете \"вкрасти\" бали у себе (або будь-кого) та передати їх своєму партнеру, потенційно вигравши свій хід його балами (їхня мета все одно залежить від кількості їх власних королів на полі).", - "rule5": "Дев'ятки можна зіграти для альтернативного подібного ефекту. Коли противник грає короля, ви можете негайно зіграти дев'ятку у відповідь, щоб повернути цю картку власнику до його руки, запобігаючи її ефекту. Це можна зробити поза чергою, і це не забирає ваш хід, але не перешкоджає власнику картки зіграти її на наступному ходу. Як альтернатива, ви все одно можете зіграти дев'ятку для її оригінального ефекту один раз на вашому ходу (використовуючи свій хід для цього), щоб повернути картку в руку її власнику та запобігти її зігранню на наступному ходу." + "rule5": "Дев'ятки можна зіграти для альтернативного подібного ефекту. Коли противник грає короля, ви можете негайно зіграти дев'ятку у відповідь, щоб повернути цю картку власнику до його руки, запобігаючи її ефекту. Це можна зробити поза чергою, і це не забирає ваш хід, але не перешкоджає власнику картки зіграти її на наступному ходу. Як альтернатива, ви все одно можете зіграти дев'ятку для її оригінального ефекту один раз на вашому ходу (використовуючи свій хід для цього), щоб покласти картку з поля вашого суперника зверху колоди." }, "faq": { "title": "FAQ", @@ -586,13 +589,12 @@ } }, "announcement": { - "activatorText": "Уже в ефірі: чемпіонат сезону Diamonds 2026!", - "title": "Чемпіонат Diamonds 2026", - "heading": "Уже в ефірі: чемпіонат сезону Cuttle", - "paragraph": "Чемпіонат сезону Diamonds 2026 у Cuttle просто зараз в ефірі! 8 найкращих гравців минулого рейтингового сезону змагаються за системою подвійного вибування за титул чемпіона сезону Diamonds 2026 у Cuttle. Дивіться наживо на", - "twitchLink": "Twitch", - "linkSeparator": "або", - "youtubeLink": "YouTube" + "activatorText": "Дев'ятки стають сильнішими!", + "title": "Бета правил: дев'ятки кладуть на колоду", + "heading": "Дев'ятки тепер кладуть карти зверху колоди", + "paragraph": "Дев'ятка — одна з найстарших карт у колоді, але її одноразовий ефект завжди був найвужчим. Тепер замість повернення карти в руку власника дев'ятка кладе карту з поля вашого суперника зверху колоди — карту очок, фігуру або верхнього Валета у стосі. Наступним ходом вони візьмуть свою ж карту, тож дев'ятка коштує їм цілого ходу. Більше нічого не заморожується, а Дами захищають як і раніше.", + "heading2": "Тестуємо впродовж сезону Пік", + "paragraph2": "Це бета-версія. Ми залишимо її на весь сезон Пік, щоб усі встигли спробувати її в справжніх партіях, а наприкінці року проведемо опитування спільноти, чи залишати її." }, "profile": { "myGames": "Мої ігри", diff --git a/tests/e2e/specs/in-game/handLimit.spec.js b/tests/e2e/specs/in-game/handLimit.spec.js index 5c0781148..7253422b2 100644 --- a/tests/e2e/specs/in-game/handLimit.spec.js +++ b/tests/e2e/specs/in-game/handLimit.spec.js @@ -410,77 +410,19 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { }); }); - describe('Nines and Hand Limit', () => { + describe('Passing and Hand Limit', () => { describe('P1 perspective', () => { beforeEach(() => { cy.setupGameAsP1(); }); - it('Skips discarding when opponent nines player at the hand limit and player then plays points', () => { + it('Forces player to discard down to hand limit after passing while over the limit with an empty deck', () => { + // The over-limit hand is seeded directly: the hand limit is only enforced at the end of + // the active player's turn, and no effect hands a card to the inactive 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: [], - topCard: Card.TWO_OF_HEARTS, - }); - - // 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) — no immediate discard - cy.get('[data-player-hand-card]').should('have.length', 9); - cy.get('#discard-to-hand-limit-dialog').should('not.exist'); - - // Player plays NINE_OF_CLUBS for points — hand drops to 8, no discard triggered - cy.get('[data-player-hand-card=9-0]').click(); - cy.get('[data-move-choice=points]').click(); - - cy.get('[data-player-hand-card]').should('have.length', 8); - cy.get('#discard-to-hand-limit-dialog').should('not.exist'); - - // Turn indicator shows it is now the opponent's turn - cy.get('#turn-indicator').contains('OPPONENT\'S TURN'); - - assertGameState(1, { p0Hand: [], 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.TEN_OF_CLUBS, - ], - p1Points: [ Card.NINE_OF_CLUBS ], - p1FaceCards: [], - scrap: [ Card.NINE_OF_SPADES ], - }); - }); - - it('Forces player to discard down to hand limit after opponent nines and player draws', () => { - cy.loadGameFixture(1, { - p0Hand: [ Card.NINE_OF_SPADES ], - p0Points: [], - p0FaceCards: [], p1Hand: [ Card.ACE_OF_CLUBS, Card.THREE_OF_CLUBS, @@ -490,91 +432,18 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { Card.SEVEN_OF_CLUBS, Card.EIGHT_OF_CLUBS, Card.NINE_OF_CLUBS, - ], - p1Points: [ Card.TEN_OF_CLUBS ], - p1FaceCards: [], - topCard: Card.TWO_OF_HEARTS, - }); - - // 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) — no immediate discard - cy.get('[data-player-hand-card]').should('have.length', 9); - cy.get('#discard-to-hand-limit-dialog').should('not.exist'); - - // Player draws, bringing hand to 10 cards - cy.get('#deck').click(); - cy.get('[data-player-hand-card]').should('have.length', 10); - - // Discard-to-hand-limit dialog appears — player must discard 2 cards - cy.get('#discard-to-hand-limit-dialog').should('be.visible'); - cy.get('[data-discard-hand-limit-card=1-0]').click(); - cy.get('[data-discard-hand-limit-card=9-0]').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'); - - 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.TEN_OF_CLUBS, - Card.TWO_OF_HEARTS, ], p1Points: [], p1FaceCards: [], - scrap: [ Card.NINE_OF_SPADES, Card.ACE_OF_CLUBS, Card.NINE_OF_CLUBS ], - }); - }); - }); - }); - - describe('Passing and Hand Limit', () => { - describe('P1 perspective', () => { - beforeEach(() => { - cy.setupGameAsP1(); - }); - - it('Forces player to discard down to hand limit after passing while over the limit with an empty deck', () => { - 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: [], // Empty deck so the player can pass (rather than draw) on their turn deck: [], }); - // Opponent (P0) plays nine targeting player's (P1's) point card, returning it to hand - 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(); + // Opponent passes, handing the turn to the player who is over the limit + cy.passOpponent(); - // Player now has 9 cards (8 in hand + TEN_OF_CLUBS returned) — no immediate discard + // Player has 9 cards and has not been asked to discard yet cy.get('[data-player-hand-card]').should('have.length', 9); cy.get('#discard-to-hand-limit-dialog').should('not.exist'); @@ -616,10 +485,11 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { p1FaceCards: [], deck: [], scrap: [ - Card.NINE_OF_SPADES, + // Discarded down to the hand limit Card.ACE_OF_CLUBS, // Cards put into scrap by loadGameFixture (empty deck sends all unused cards to scrap) Card.TWO_OF_CLUBS, + Card.NINE_OF_SPADES, Card.JACK_OF_CLUBS, Card.QUEEN_OF_CLUBS, Card.KING_OF_CLUBS, diff --git a/tests/e2e/specs/in-game/one-offs/7_sevens/opponent_sevens.spec.js b/tests/e2e/specs/in-game/one-offs/7_sevens/opponent_sevens.spec.js index 83f066d12..ab2bd6902 100644 --- a/tests/e2e/specs/in-game/one-offs/7_sevens/opponent_sevens.spec.js +++ b/tests/e2e/specs/in-game/one-offs/7_sevens/opponent_sevens.spec.js @@ -1,6 +1,5 @@ -import { assertGameState, assertSnackbar } from '../../../../support/helpers'; +import { assertGameState } from '../../../../support/helpers'; import { Card } from '../../../../fixtures/cards'; -import { SnackBarError } from '../../../../fixtures/snackbarError'; describe('Opponent playing SEVENS', () => { beforeEach(() => { @@ -504,6 +503,20 @@ describe('Opponent playing SEVENS', () => { cy.get('[data-cy=cannot-counter-resolve]').should('be.visible') .click(); + // The queen went to the top of the deck, not back to the player's hand + assertGameState(1, { + p0Hand: [], + p0Points: [], + p0FaceCards: [], + p1Hand: [], + p1Points: [], + p1FaceCards: [ Card.KING_OF_HEARTS ], + scrap: [ Card.NINE_OF_DIAMONDS, Card.SEVEN_OF_CLUBS ], + }); + + // Player draws their queen straight back off the top + cy.get('#deck').click(); + assertGameState(1, { p0Hand: [], p0Points: [], @@ -559,44 +572,36 @@ describe('Opponent playing SEVENS', () => { cy.get('[data-cy=cannot-counter-resolve]').should('be.visible') .click(); + // The jack went to the top of the deck, and the ace it was stealing reverts to its owner assertGameState(1, { p0Hand: [ Card.TEN_OF_DIAMONDS ], p0Points: [ Card.ACE_OF_CLUBS ], p0FaceCards: [], - p1Hand: [ Card.JACK_OF_CLUBS ], + p1Hand: [], p1Points: [], p1FaceCards: [ Card.KING_OF_HEARTS ], scrap: [ Card.NINE_OF_CLUBS, Card.SEVEN_OF_CLUBS ], }); - // player plays the returned jack immediately - cy.get('[data-player-hand-card=11-0]').click(); - // Frozen move choice cards should be disabled and display frozen text. - cy.get('[data-move-choice=jack]') - .should('have.class', 'v-card--disabled') - .contains('This card is frozen') - .click({ force: true }); // Break out into separate test case - cy.get('[data-opponent-point-card=1-0]').click(); - assertSnackbar(SnackBarError.FROZEN_CARD); - cy.log('Successfully prevented player from playing the jack while it is frozen'); - - // Player draws + // Player draws, getting their own jack back rather than the fixture's top card cy.get('#deck').click(); assertGameState(1, { p0Hand: [ Card.TEN_OF_DIAMONDS ], p0Points: [ Card.ACE_OF_CLUBS ], p0FaceCards: [], - p1Hand: [ Card.JACK_OF_CLUBS, Card.FOUR_OF_CLUBS ], + p1Hand: [ Card.JACK_OF_CLUBS ], p1Points: [], p1FaceCards: [ Card.KING_OF_HEARTS ], scrap: [ Card.NINE_OF_CLUBS, Card.SEVEN_OF_CLUBS ], }); + // The jack is playable right away; nothing is frozen any more + cy.get('[data-player-hand-card=11-0]').should('not.have.class', 'frozen'); cy.playPointsOpponent(Card.TEN_OF_DIAMONDS); - cy.get('[data-player-hand-card]').should('have.length', 2); + cy.get('[data-player-hand-card]').should('have.length', 1); - // Player plays the returned jack + // Player replays the jack they just drew cy.get('[data-player-hand-card=11-0]').click(); cy.get('[data-move-choice=jack]').click(); cy.get('[data-opponent-point-card=1-0]').click(); @@ -605,7 +610,7 @@ describe('Opponent playing SEVENS', () => { p0Hand: [], p0Points: [ Card.TEN_OF_DIAMONDS ], p0FaceCards: [], - p1Hand: [ Card.FOUR_OF_CLUBS ], + p1Hand: [], p1Points: [ Card.ACE_OF_CLUBS ], p1FaceCards: [ Card.KING_OF_HEARTS ], scrap: [ Card.NINE_OF_CLUBS, Card.SEVEN_OF_CLUBS ], diff --git a/tests/e2e/specs/in-game/one-offs/7_sevens/player_sevens.spec.js b/tests/e2e/specs/in-game/one-offs/7_sevens/player_sevens.spec.js index 2a7e02940..5ac01dc4f 100644 --- a/tests/e2e/specs/in-game/one-offs/7_sevens/player_sevens.spec.js +++ b/tests/e2e/specs/in-game/one-offs/7_sevens/player_sevens.spec.js @@ -847,11 +847,12 @@ describe('Playing SEVENS', () => { cy.resolveOpponent(); cy.get('#waiting-for-opponent-counter-scrim').should('not.exist'); + // The queen went to the top of the deck, not back to the opponent's hand assertGameState(0, { p0Hand: [], p0Points: [], p0FaceCards: [], - p1Hand: [ Card.QUEEN_OF_CLUBS ], + p1Hand: [], p1Points: [], p1FaceCards: [ Card.KING_OF_HEARTS ], scrap: [ Card.NINE_OF_DIAMONDS, Card.SEVEN_OF_CLUBS ], @@ -902,11 +903,12 @@ describe('Playing SEVENS', () => { cy.resolveOpponent(); cy.get('#waiting-for-opponent-counter-scrim').should('not.exist'); + // The jack went to the top of the deck, and the ace it was stealing reverts to its owner assertGameState(0, { p0Hand: [], p0Points: [ Card.ACE_OF_CLUBS ], p0FaceCards: [], - p1Hand: [ Card.JACK_OF_CLUBS ], + p1Hand: [], p1Points: [], p1FaceCards: [ Card.KING_OF_HEARTS ], scrap: [ Card.NINE_OF_DIAMONDS, Card.SEVEN_OF_CLUBS ], 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 c6d098b0f..c0ba29cfb 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 @@ -2,21 +2,10 @@ import { assertGameState, assertSnackbar } from '../../../support/helpers'; import { Card } from '../../../fixtures/cards'; import { SnackBarError } from '../../../fixtures/snackbarError'; -function assertCardIsFrozen(card) { - const cardSelector = `[data-player-hand-card=${card.rank}-${card.suit}]`; - cy.get(cardSelector) - .should('have.class', 'frozen') - .click(); - - - // Card overlay should have the frozen state shown visually - cy.get(`[data-player-overlay-card=${card.rank}-${card.suit}]`).should('have.class', 'frozen'); - // Frozen move choice cards should be disabled and display frozen text. - cy.get('[data-move-choice=points]') - .should('have.class', 'v-card--disabled') - .contains('This card is frozen') - .click({ force: true }); // Break out into separate test case - assertSnackbar(SnackBarError.FROZEN_CARD); +function assertDeckCount(count) { + cy.get('#deck .c-deck-count').invoke('text') + .invoke('trim') + .should('eq', `(${count})`); } describe('Playing NINES', () => { @@ -52,7 +41,7 @@ describe('Playing NINES', () => { }); }); // End 9 scuttle - it('Plays a nine as ONE-OFF on lower point card to return it to owners hand', () => { + it('Plays a nine as ONE-OFF on a lower point card to put it on top of the deck', () => { cy.loadGameFixture(0, { p0Hand: [ Card.NINE_OF_SPADES, Card.NINE_OF_HEARTS ], p0Points: [ Card.TEN_OF_HEARTS ], @@ -60,29 +49,54 @@ describe('Playing NINES', () => { p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [ Card.ACE_OF_DIAMONDS ], p1FaceCards: [], + topCard: Card.TWO_OF_CLUBS, }); + assertDeckCount(46); // Player plays nine cy.get('[data-player-hand-card=9-3]').click(); // nine of spades - cy.get('[data-move-choice=targetedOneOff').click(); + cy.get('[data-move-choice=targetedOneOff]').click(); cy.get('[data-opponent-point-card=1-1]').click(); // ace of diamonds // Wait for opponent to resolve cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); cy.resolveOpponent(); + // The ace is gone from the board, and it did NOT go to anyone's hand assertGameState(0, { p0Hand: [ Card.NINE_OF_HEARTS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS, Card.ACE_OF_DIAMONDS ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], }); + assertDeckCount(47); + + // The opponent's next draw is the card they just lost, proving it was on top + cy.drawCardOpponent(); + assertDeckCount(46); + + // Player draws the card that was underneath it + cy.get('#deck').click(); + assertDeckCount(45); + + // Opponent replays the ace, so it was never frozen + cy.playPointsOpponent(Card.ACE_OF_DIAMONDS); + + assertGameState(0, { + p0Hand: [ Card.NINE_OF_HEARTS, Card.TWO_OF_CLUBS ], + p0Points: [ Card.TEN_OF_HEARTS ], + p0FaceCards: [], + p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], + p1Points: [ Card.ACE_OF_DIAMONDS ], + p1FaceCards: [], + scrap: [ Card.NINE_OF_SPADES ], + }); }); // End 9 one-off low point card - it('Plays a nine as ONE-OFF on a higher point card to return it to owners hand', () => { + it('Plays a nine as ONE-OFF on a higher point card to put it on top of the deck', () => { cy.loadGameFixture(0, { p0Hand: [ Card.NINE_OF_CLUBS, Card.NINE_OF_HEARTS ], p0Points: [ Card.TEN_OF_HEARTS ], @@ -91,6 +105,7 @@ describe('Playing NINES', () => { p1Points: [ Card.NINE_OF_SPADES ], p1FaceCards: [], }); + assertDeckCount(46); // Attempt illegal scuttle cy.get('[data-player-hand-card=9-0]').click(); @@ -117,14 +132,15 @@ describe('Playing NINES', () => { p0Hand: [ Card.NINE_OF_CLUBS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.NINE_OF_SPADES, Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_HEARTS ], }); + assertDeckCount(47); }); // End 9 one-off high-point card - it('Plays a nine as a ONE-OFF to return a face card to its owners hand', () => { + it('Plays a nine as a ONE-OFF to put a face card on top of the deck', () => { cy.loadGameFixture(0, { p0Hand: [ Card.NINE_OF_SPADES, Card.NINE_OF_HEARTS ], p0Points: [ Card.TEN_OF_HEARTS ], @@ -133,6 +149,7 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [ Card.KING_OF_DIAMONDS ], }); + assertDeckCount(46); // Player plays nine cy.get('[data-player-hand-card=9-3]').click(); // nine of spades @@ -148,14 +165,15 @@ describe('Playing NINES', () => { p0Hand: [ Card.NINE_OF_HEARTS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS, Card.KING_OF_DIAMONDS ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], }); + assertDeckCount(47); }); // End 9 on face card - it('Plays a 9 on a jack to steal back point card', () => { + it('Plays a 9 on a jack to steal back point card, topdecking the jack', () => { cy.loadGameFixture(0, { p0Hand: [ Card.ACE_OF_SPADES, Card.NINE_OF_CLUBS ], p0Points: [ Card.TEN_OF_SPADES ], @@ -163,7 +181,9 @@ describe('Playing NINES', () => { p1Hand: [ Card.JACK_OF_CLUBS ], p1Points: [], p1FaceCards: [], + topCard: Card.TWO_OF_CLUBS, }); + assertDeckCount(48); // Player plays Ace of Spades cy.get('[data-player-hand-card=1-3]').click(); @@ -190,7 +210,7 @@ describe('Playing NINES', () => { p1FaceCards: [], }); - // Player plays NINE to destroy jack + // Player plays NINE to topdeck the jack cy.get('[data-player-hand-card=9-0]').click(); cy.get('[data-move-choice=targetedOneOff]').click(); cy.get('#player-hand-targeting').should('be.visible'); @@ -200,19 +220,39 @@ describe('Playing NINES', () => { cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); cy.resolveOpponent(); + // The jack went to the deck; the point card it was stealing reverts to its owner assertGameState(0, { p0Hand: [], p0Points: [ Card.ACE_OF_SPADES, Card.TEN_OF_SPADES ], p0FaceCards: [], - p1Hand: [ Card.JACK_OF_CLUBS ], + p1Hand: [], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS ], }); + assertDeckCount(49); // Should no longer see jack of clubs on screen cy.get('[data-player-face-card=11-0]').should('not.exist'); - }); // End 9 on jack + + // Opponent draws the topdecked jack back, and player draws the card beneath it + cy.drawCardOpponent(); + cy.get('#deck').click(); + assertDeckCount(47); + + // Opponent immediately replays the jack, so it was never frozen + cy.playJackOpponent(Card.JACK_OF_CLUBS, Card.ACE_OF_SPADES); + + assertGameState(0, { + p0Hand: [ Card.TWO_OF_CLUBS ], + p0Points: [ Card.TEN_OF_SPADES ], + p0FaceCards: [], + p1Hand: [], + p1Points: [ Card.ACE_OF_SPADES ], + p1FaceCards: [], + scrap: [ Card.NINE_OF_CLUBS ], + }); + }); // End 9 on jack it('Plays a 9 on a triple jack to steal back jacked point card', () => { cy.loadGameFixture(0, { @@ -223,6 +263,7 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [], }); + assertDeckCount(45); // Player plays Ace of Spades cy.get('[data-player-hand-card=1-3]').click(); @@ -249,7 +290,6 @@ describe('Playing NINES', () => { p1FaceCards: [], }); - // player plays jack cy.get('[data-player-hand-card=11-3]').click(); cy.get('[data-move-choice=jack]').click(); @@ -259,10 +299,15 @@ describe('Playing NINES', () => { // Opponent plays jack cy.playJackOpponent(Card.JACK_OF_DIAMONDS, Card.ACE_OF_SPADES); - // player plays 9 on jack to steal back card + // player plays 9 on the jack stack to steal back the card cy.get('[data-player-hand-card=9-0]').click(); cy.get('[data-move-choice=targetedOneOff]').click(); cy.get('#player-hand-targeting').should('be.visible'); + + // Only the top jack of the stack is a legal target + cy.get('.jacks-container').find('.target-overlay') + .should('have.length', 1); + cy.get('[data-opponent-face-card=11-1]').click(); cy.resolveOpponent(); @@ -271,23 +316,27 @@ describe('Playing NINES', () => { p0Hand: [], p0Points: [ Card.TEN_OF_SPADES, Card.ACE_OF_SPADES ], p0FaceCards: [], - p1Hand: [ Card.JACK_OF_DIAMONDS, Card.NINE_OF_HEARTS ], + p1Hand: [ Card.NINE_OF_HEARTS ], p1Points: [], p1FaceCards: [], + scrap: [ Card.NINE_OF_CLUBS ], }); + assertDeckCount(46); - // Opponent nines Jack + // Opponent nines the jack that is now on top of the player's ace cy.playTargetedOneOffOpponent(Card.NINE_OF_HEARTS, Card.JACK_OF_SPADES, 'jack'); cy.get('[data-cy=cannot-counter-resolve]').click(); - + assertGameState(0, { - p0Hand: [ Card.JACK_OF_SPADES ], - p0Points: [ Card.TEN_OF_SPADES, ], + p0Hand: [], + p0Points: [ Card.TEN_OF_SPADES ], p0FaceCards: [], - p1Hand: [ Card.JACK_OF_DIAMONDS ], + p1Hand: [], p1Points: [ Card.ACE_OF_SPADES ], p1FaceCards: [], + scrap: [ Card.NINE_OF_CLUBS, Card.NINE_OF_HEARTS ], }); + assertDeckCount(47); }); it('Plays a 9 on a point card that has been triple jacked', () => { @@ -298,34 +347,17 @@ describe('Playing NINES', () => { p1Hand: [ Card.JACK_OF_CLUBS, Card.JACK_OF_DIAMONDS, Card.NINE_OF_HEARTS ], p1Points: [], p1FaceCards: [], + topCard: Card.TWO_OF_CLUBS, }); + assertDeckCount(45); // Player plays Ace of Spades cy.get('[data-player-hand-card=1-3]').click(); cy.get('[data-move-choice=points]').click(); - assertGameState(0, { - p0Hand: [ Card.NINE_OF_CLUBS, Card.JACK_OF_SPADES ], - p0Points: [ Card.TEN_OF_SPADES, Card.ACE_OF_SPADES ], - p0FaceCards: [], - p1Hand: [ Card.JACK_OF_CLUBS, Card.JACK_OF_DIAMONDS, Card.NINE_OF_HEARTS ], - p1Points: [], - p1FaceCards: [], - }); - // Opponent plays jack cy.playJackOpponent(Card.JACK_OF_CLUBS, Card.ACE_OF_SPADES); - assertGameState(0, { - p0Hand: [ Card.NINE_OF_CLUBS, Card.JACK_OF_SPADES ], - p0Points: [ Card.TEN_OF_SPADES ], - p0FaceCards: [], - p1Hand: [ Card.JACK_OF_DIAMONDS, Card.NINE_OF_HEARTS ], - p1Points: [ Card.ACE_OF_SPADES ], - p1FaceCards: [], - }); - - // player plays jack cy.get('[data-player-hand-card=11-3]').click(); cy.get('[data-move-choice=jack]').click(); @@ -335,7 +367,7 @@ describe('Playing NINES', () => { // Opponent plays jack cy.playJackOpponent(Card.JACK_OF_DIAMONDS, Card.ACE_OF_SPADES); - // player plays 9 + // player plays 9 on the point card itself cy.get('[data-player-hand-card=9-0]').click(); cy.get('[data-move-choice=targetedOneOff]').click(); cy.get('#player-hand-targeting').should('be.visible'); @@ -343,14 +375,33 @@ describe('Playing NINES', () => { cy.resolveOpponent(); + // The ace is topdecked and all three jacks are scrapped assertGameState(0, { - p0Hand: [ ], + p0Hand: [], p0Points: [ Card.TEN_OF_SPADES ], p0FaceCards: [], - p1Hand: [ Card.NINE_OF_HEARTS, Card.ACE_OF_SPADES ], + p1Hand: [ Card.NINE_OF_HEARTS ], p1Points: [], p1FaceCards: [], - scrap: [ Card.JACK_OF_DIAMONDS, Card.JACK_OF_CLUBS, Card.JACK_OF_SPADES, Card.NINE_OF_CLUBS ] + scrap: [ Card.JACK_OF_DIAMONDS, Card.JACK_OF_CLUBS, Card.JACK_OF_SPADES, Card.NINE_OF_CLUBS ], + }); + assertDeckCount(46); + + // Opponent draws the ace back, and the player draws the card beneath it + cy.drawCardOpponent(); + cy.get('#deck').click(); + assertDeckCount(44); + + cy.playPointsOpponent(Card.ACE_OF_SPADES); + + assertGameState(0, { + p0Hand: [ Card.TWO_OF_CLUBS ], + p0Points: [ Card.TEN_OF_SPADES ], + p0FaceCards: [], + p1Hand: [ Card.NINE_OF_HEARTS ], + p1Points: [ Card.ACE_OF_SPADES ], + p1FaceCards: [], + scrap: [ Card.JACK_OF_DIAMONDS, Card.JACK_OF_CLUBS, Card.JACK_OF_SPADES, Card.NINE_OF_CLUBS ], }); }); @@ -382,14 +433,15 @@ describe('Playing NINES', () => { p1FaceCards: [], scrap: [], }); + assertDeckCount(46); }); - it('Plays a nine as a ONE-OFF, make sure that the bounced card is playable later', () => { + it('Plays a nine as a ONE-OFF; the topdecked card is drawn back and replayed immediately', () => { /* - 1). P0 plays a 9, targeting an in-play Queen + 1). P0 plays a 9, topdecking an in-play Queen 2). P1 plays a 6's one-off, removing some cards 3). P0 plays a Jack, targeting an in-play 10 - 4). P1 can replay their bounced Queen + 4). P1 draws their topdecked Queen and replays it with no delay */ // Initial state @@ -400,10 +452,12 @@ describe('Playing NINES', () => { p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [ Card.TEN_OF_HEARTS ], p1FaceCards: [ Card.QUEEN_OF_DIAMONDS ], + topCard: Card.TWO_OF_CLUBS, }); + assertDeckCount(45); // STEP 1 - cy.log('STEP 1- P0 plays nine, targeting the Queen in play'); + cy.log('STEP 1- P0 plays nine, topdecking the Queen in play'); cy.get('[data-player-hand-card=9-3]').click(); // nine of spades cy.get('[data-move-choice=targetedOneOff]').click(); cy.get('#player-hand-targeting').should('be.visible'); @@ -417,11 +471,12 @@ describe('Playing NINES', () => { p0Hand: [ Card.JACK_OF_SPADES ], p0Points: [], p0FaceCards: [ Card.KING_OF_HEARTS ], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS, Card.QUEEN_OF_DIAMONDS ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [ Card.TEN_OF_HEARTS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], }); + assertDeckCount(46); // STEP 2 cy.log('STEP 2- P1 plays a six, removing face cards'); @@ -435,7 +490,7 @@ describe('Playing NINES', () => { p0Hand: [ Card.JACK_OF_SPADES ], p0Points: [], p0FaceCards: [], - p1Hand: [ Card.QUEEN_OF_HEARTS, Card.QUEEN_OF_DIAMONDS ], + p1Hand: [ Card.QUEEN_OF_HEARTS ], p1Points: [ Card.TEN_OF_HEARTS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES, Card.KING_OF_HEARTS, Card.SIX_OF_HEARTS ], @@ -451,18 +506,22 @@ describe('Playing NINES', () => { p0Hand: [], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.QUEEN_OF_HEARTS, Card.QUEEN_OF_DIAMONDS ], + p1Hand: [ Card.QUEEN_OF_HEARTS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES, Card.KING_OF_HEARTS, Card.SIX_OF_HEARTS ], }); // STEP 4 - cy.log('STEP 4- P1 plays their previously bounced Queen'); + cy.log('STEP 4- P1 draws their topdecked Queen and plays it right away'); + cy.drawCardOpponent(); + cy.get('#deck').click(); + assertDeckCount(44); + cy.playFaceCardOpponent(Card.QUEEN_OF_DIAMONDS); assertGameState(0, { - p0Hand: [], + p0Hand: [ Card.TWO_OF_CLUBS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], p1Hand: [ Card.QUEEN_OF_HEARTS ], @@ -472,13 +531,13 @@ describe('Playing NINES', () => { }); }); - it('Plays a nine as a ONE-OFF, make sure that the bounced card is playable even if there is countering', () => { + it('Plays a nine as a ONE-OFF; the topdecked card returns even when a later one-off is countered', () => { /* - 1). P0 plays 9 one-off, targeting P1's Queen + 1). P0 plays 9 one-off, topdecking P1's Queen 2). P1 plays 6's one-off - 3). P0 counters and it resolves + 3). P0 counters and it fizzles 4). P0 plays their Jack, targeting P1's 10 - 5). P1 plays their Queen again (it should no longer be frozen) + 5). P1 draws their Queen back and plays it again */ // Initial state @@ -489,10 +548,12 @@ describe('Playing NINES', () => { p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [ Card.TEN_OF_HEARTS ], p1FaceCards: [ Card.QUEEN_OF_DIAMONDS ], + topCard: Card.TWO_OF_CLUBS, }); + assertDeckCount(44); // STEP 1 - cy.log('STEP 1- P0 plays nine, targeting the Queen in play'); + cy.log('STEP 1- P0 plays nine, topdecking the Queen in play'); cy.get('[data-player-hand-card=9-3]').click(); // nine of spades cy.get('[data-move-choice=targetedOneOff]').click(); cy.get('#player-hand-targeting').should('be.visible'); @@ -506,11 +567,12 @@ describe('Playing NINES', () => { p0Hand: [ Card.TWO_OF_SPADES, Card.JACK_OF_SPADES ], p0Points: [], p0FaceCards: [ Card.KING_OF_HEARTS ], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS, Card.QUEEN_OF_DIAMONDS ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [ Card.TEN_OF_HEARTS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], }); + assertDeckCount(45); // STEP 2 cy.log('STEP 2- P1 tries to play a six'); @@ -535,7 +597,7 @@ describe('Playing NINES', () => { p0Hand: [ Card.JACK_OF_SPADES ], p0Points: [], p0FaceCards: [ Card.KING_OF_HEARTS ], - p1Hand: [ Card.QUEEN_OF_HEARTS, Card.QUEEN_OF_DIAMONDS ], + p1Hand: [ Card.QUEEN_OF_HEARTS ], p1Points: [ Card.TEN_OF_HEARTS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES, Card.SIX_OF_HEARTS, Card.TWO_OF_SPADES ], @@ -548,12 +610,16 @@ describe('Playing NINES', () => { cy.get('#player-hand-targeting').should('be.visible'); cy.get('[data-opponent-point-card=10-2]').click(); // Ten of hearts - // STEP 4 - cy.log('STEP 5- P1 plays their previously bounced Queen'); + // STEP 5 + cy.log('STEP 5- P1 draws their topdecked Queen and plays it'); + cy.drawCardOpponent(); + cy.get('#deck').click(); + assertDeckCount(43); + cy.playFaceCardOpponent(Card.QUEEN_OF_DIAMONDS); assertGameState(0, { - p0Hand: [], + p0Hand: [ Card.TWO_OF_CLUBS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [ Card.KING_OF_HEARTS ], p1Hand: [ Card.QUEEN_OF_HEARTS ], @@ -569,7 +635,7 @@ describe('Playing NINES', () => { cy.setupGameAsP1(); }); - it('Disables playing a frozen number card until the following turn', () => { + it('Topdecks the point card, which the player draws back and plays immediately', () => { cy.loadGameFixture(1, { p0Hand: [ Card.NINE_OF_CLUBS ], p0Points: [ Card.THREE_OF_CLUBS ], @@ -579,8 +645,9 @@ describe('Playing NINES', () => { p1FaceCards: [], topCard: Card.TEN_OF_CLUBS, }); + assertDeckCount(49); - // opponent plays nine to return seven to player's hand + // opponent plays nine to topdeck the player's seven cy.playTargetedOneOffOpponent(Card.NINE_OF_CLUBS, Card.SEVEN_OF_CLUBS, 'point'); // Player resolves @@ -588,6 +655,23 @@ describe('Playing NINES', () => { .get('[data-cy=cannot-counter-resolve]') .click(); + // The seven did not come back to the player's hand + assertGameState(1, { + p0Hand: [], + p0Points: [ Card.THREE_OF_CLUBS ], + p0FaceCards: [], + p1Hand: [], + p1Points: [], + p1FaceCards: [], + scrap: [ Card.NINE_OF_CLUBS ], + }); + cy.get('[data-player-hand-card]').should('not.exist'); + assertDeckCount(50); + + // Player draws and gets their own seven back off the top + cy.get('#deck').click(); + assertDeckCount(49); + assertGameState(1, { p0Hand: [], p0Points: [ Card.THREE_OF_CLUBS ], @@ -595,23 +679,29 @@ describe('Playing NINES', () => { p1Hand: [ Card.SEVEN_OF_CLUBS ], p1Points: [], p1FaceCards: [], + scrap: [ Card.NINE_OF_CLUBS ], }); + // Nothing is frozen any more + cy.get('[data-player-hand-card=7-0]').should('not.have.class', 'frozen'); - assertCardIsFrozen(Card.SEVEN_OF_CLUBS); + cy.drawCardOpponent(); + assertDeckCount(48); - // Player then tries to scuttle + // Player scuttles with the card that was topdecked cy.get('[data-player-hand-card=7-0]').click(); - // Card overlay should have the frozen state shown visually - cy.get('[data-player-overlay-card=7-0]').should('have.class', 'frozen'); - // Frozen move choice cards should be disabled and display frozen text. - cy.get('[data-move-choice=scuttle]') - .should('have.class', 'v-card--disabled') - .contains('This card is frozen') - .click({ force: true }); // Break out into separate test case - // Player attempts to scuttle lower point card + cy.get('[data-move-choice=scuttle]').click(); cy.get('#player-hand-targeting').should('be.visible'); cy.get('[data-opponent-point-card=3-0]').click(); - assertSnackbar(SnackBarError.FROZEN_CARD); + + assertGameState(1, { + p0Hand: [ Card.TEN_OF_CLUBS ], + p0Points: [], + p0FaceCards: [], + p1Hand: [], + p1Points: [], + p1FaceCards: [], + scrap: [ Card.NINE_OF_CLUBS, Card.SEVEN_OF_CLUBS, Card.THREE_OF_CLUBS ], + }); }); it('Opponent plays a NINE on a jack to steal back point card', () => { @@ -624,6 +714,7 @@ describe('Playing NINES', () => { p1FaceCards: [], topCard: Card.TEN_OF_CLUBS, }); + assertDeckCount(46); // opponent plays Ace of Spades cy.playPointsOpponent(Card.ACE_OF_SPADES); @@ -650,59 +741,48 @@ describe('Playing NINES', () => { .get('[data-cy=cannot-counter-resolve]') .click(); + // The jack went to the deck rather than back to the player's hand assertGameState(1, { p0Hand: [ Card.ACE_OF_DIAMONDS ], p0Points: [ Card.TEN_OF_SPADES, Card.ACE_OF_SPADES ], p0FaceCards: [], - p1Hand: [ Card.JACK_OF_CLUBS, Card.TEN_OF_DIAMONDS ], + p1Hand: [ Card.TEN_OF_DIAMONDS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS ], }); - // Card should have the frozen state shown visually - cy.get('[data-player-hand-card=11-0]').should('have.class', 'frozen'); - - // Player attempts plays the returned jack immediately - cy.get('[data-player-hand-card=11-0]').click(); - - // Card overlay should have the frozen state shown visually - cy.get('[data-player-overlay-card=11-0]').should('have.class', 'frozen'); - - // Frozen move choice cards should be disabled and display frozen text. - cy.get('[data-move-choice=jack]') - .should('have.class', 'v-card--disabled') - .contains('This card is frozen') - .click({ force: true }); // Break out into separate test case - - cy.get('#player-hand-targeting').should('be.visible'); - cy.get('[data-opponent-point-card=1-3]').click(); - assertSnackbar(SnackBarError.FROZEN_CARD); - cy.log('Correctly prevented player from re-playing frozen card next turn'); + assertDeckCount(47); cy.get('[data-player-hand-card=10-1]').click(); cy.get('[data-move-choice=points]').click(); + cy.playPointsOpponent(Card.ACE_OF_DIAMONDS); + + // Player draws their jack back off the top of the deck + cy.get('#deck').click(); + assertDeckCount(46); + assertGameState(1, { - p0Hand: [ Card.ACE_OF_DIAMONDS ], - p0Points: [ Card.TEN_OF_SPADES, Card.ACE_OF_SPADES ], + p0Hand: [], + p0Points: [ Card.TEN_OF_SPADES, Card.ACE_OF_SPADES, Card.ACE_OF_DIAMONDS ], p0FaceCards: [], p1Hand: [ Card.JACK_OF_CLUBS ], p1Points: [ Card.TEN_OF_DIAMONDS ], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS ], }); + cy.get('[data-player-hand-card=11-0]').should('not.have.class', 'frozen'); - cy.playPointsOpponent(Card.ACE_OF_DIAMONDS); - cy.get('[data-player-hand-card]').should('have.length', 1); + cy.drawCardOpponent(); - // Player plays jack after one turn + // Player replays the jack cy.get('[data-player-hand-card=11-0]').click(); cy.get('[data-move-choice=jack]').click(); cy.get('#player-hand-targeting').should('be.visible'); cy.get('[data-opponent-point-card=1-1]').click(); assertGameState(1, { - p0Hand: [], + p0Hand: [ Card.TEN_OF_CLUBS ], p0Points: [ Card.TEN_OF_SPADES, Card.ACE_OF_SPADES ], p0FaceCards: [], p1Hand: [], @@ -712,7 +792,7 @@ describe('Playing NINES', () => { }); }); // End 9 on jack - it('Keeps card frozen after requesting a stalemate', () => { + it('Keeps the topdecked card on the deck through a rejected stalemate and a reload', () => { cy.loadGameFixture(1, { p0Hand: [ Card.NINE_OF_CLUBS ], p0Points: [ Card.THREE_OF_CLUBS ], @@ -723,7 +803,7 @@ describe('Playing NINES', () => { topCard: Card.TEN_OF_CLUBS, }); - // opponent plays nine to return seven to player's hand + // opponent plays nine to topdeck the player's seven cy.playTargetedOneOffOpponent(Card.NINE_OF_CLUBS, Card.SEVEN_OF_CLUBS, 'point'); // Player resolves @@ -731,14 +811,7 @@ describe('Playing NINES', () => { .get('[data-cy=cannot-counter-resolve]') .click(); - assertGameState(1, { - p0Hand: [], - p0Points: [ Card.THREE_OF_CLUBS ], - p0FaceCards: [], - p1Hand: [ Card.SEVEN_OF_CLUBS ], - p1Points: [], - p1FaceCards: [], - }); + assertDeckCount(50); // Player requests stalemate; opponent rejects cy.get('#game-menu-activator').click(); @@ -755,58 +828,39 @@ describe('Playing NINES', () => { cy.rejectStalemateOpponent(); cy.get('#waiting-for-opponent-stalemate-scrim').should('not.exist'); - // Bounced card is frozen - assertCardIsFrozen(Card.SEVEN_OF_CLUBS); + cy.get('[data-player-hand-card]').should('not.exist'); + assertDeckCount(50); - // Still frozen after reload + // Still on the deck after reload cy.reload(); - assertCardIsFrozen(Card.SEVEN_OF_CLUBS); + cy.get('[data-player-hand-card]').should('not.exist'); + assertDeckCount(50); assertGameState(1, { p0Hand: [], p0Points: [ Card.THREE_OF_CLUBS ], p0FaceCards: [], - p1Hand: [ Card.SEVEN_OF_CLUBS ], + p1Hand: [], p1Points: [], p1FaceCards: [], + scrap: [ Card.NINE_OF_CLUBS ], }); - }); - - it('Clears players frozen card after resolving one-off', () => { - cy.loadGameFixture(1, { - p0Hand: [ Card.NINE_OF_SPADES, Card.EIGHT_OF_HEARTS ], - p0Points: [], - p0FaceCards: [ Card.KING_OF_HEARTS ], - p1Hand: [ Card.NINE_OF_HEARTS, Card.EIGHT_OF_DIAMONDS ], - p1Points: [], - p1FaceCards: [ Card.KING_OF_DIAMONDS ], - }); - cy.playTargetedOneOffOpponent(Card.NINE_OF_SPADES, Card.KING_OF_DIAMONDS, 'faceCard'); - cy.get('#cannot-counter-dialog').should('be.visible') - .get('[data-cy=cannot-counter-resolve]') - .click(); - - cy.get('[data-player-hand-card=9-2]').click(); - cy.get('[data-move-choice=targetedOneOff').click(); - cy.get('[data-opponent-face-card=13-2]').click(); - cy.resolveOpponent(); - cy.playPointsOpponent(Card.EIGHT_OF_HEARTS); - cy.get('[data-player-hand-card=13-1]').click(); - cy.get('[data-move-choice=faceCard]').click(); + // The seven is still the very next card off the deck + cy.get('#deck').click(); assertGameState(1, { - p0Hand: [ Card.KING_OF_HEARTS ], - p0Points: [ Card.EIGHT_OF_HEARTS ], + p0Hand: [], + p0Points: [ Card.THREE_OF_CLUBS ], p0FaceCards: [], - p1Hand: [ Card.EIGHT_OF_DIAMONDS ], + p1Hand: [ Card.SEVEN_OF_CLUBS ], p1Points: [], - p1FaceCards: [ Card.KING_OF_DIAMONDS ], - scrap: [ Card.NINE_OF_HEARTS, Card.NINE_OF_SPADES ], + p1FaceCards: [], + scrap: [ Card.NINE_OF_CLUBS ], }); }); - it('Nine returns card to player hand at hand limit', () => { + it('Does not trigger discard-to-hand-limit for the player at the hand limit', () => { cy.loadGameFixture(1, { p0Hand: [ Card.NINE_OF_SPADES ], p0Points: [], @@ -824,6 +878,7 @@ describe('Playing NINES', () => { p1Points: [ Card.TEN_OF_CLUBS ], p1FaceCards: [], }); + assertDeckCount(42); // Opponent (P0) plays nine targeting player's (P1's) point card cy.playTargetedOneOffOpponent(Card.NINE_OF_SPADES, Card.TEN_OF_CLUBS, 'point'); @@ -831,6 +886,7 @@ describe('Playing NINES', () => { .get('[data-cy=cannot-counter-resolve]') .click(); + // The ten went to the deck, so the player's hand never grows past the limit assertGameState(1, { p0Hand: [], p0Points: [], @@ -844,21 +900,22 @@ describe('Playing NINES', () => { Card.SEVEN_OF_CLUBS, Card.EIGHT_OF_CLUBS, Card.NINE_OF_CLUBS, - Card.TEN_OF_CLUBS, ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], }); + assertDeckCount(43); + cy.get('#discard-to-hand-limit-dialog').should('not.exist'); }); }); // End Opponent playing NINES describe - describe('Nine triggers discard-to-hand-limit', () => { + describe('Nine does not trigger discard-to-hand-limit', () => { beforeEach(() => { cy.setupGameAsP0(); }); - it('Nine returns card to opponent hand at hand limit', () => { + it('Does not trigger discard-to-hand-limit for the opponent at the hand limit', () => { cy.loadGameFixture(0, { p0Hand: [ Card.NINE_OF_SPADES ], p0Points: [], @@ -876,6 +933,7 @@ describe('Playing NINES', () => { p1Points: [ Card.TEN_OF_DIAMONDS ], p1FaceCards: [], }); + assertDeckCount(42); // Player plays nine as targeted one-off against opponent's point card cy.get('[data-player-hand-card=9-3]').click(); @@ -897,13 +955,61 @@ describe('Playing NINES', () => { Card.SIX_OF_HEARTS, Card.SEVEN_OF_HEARTS, Card.EIGHT_OF_HEARTS, - Card.TEN_OF_DIAMONDS, ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], }); + assertDeckCount(43); + cy.get('#discard-to-hand-limit-dialog').should('not.exist'); }); + }); // End Nine does not trigger discard-to-hand-limit + + describe('Playing NINES with an empty deck', () => { + beforeEach(() => { + cy.setupGameAsP0(); + }); + + it('Puts a card back on an empty deck, replacing the pass with a draw', () => { + cy.loadGameFixture(0, { + p0Hand: [ Card.NINE_OF_SPADES ], + p0Points: [], + p0FaceCards: [], + p1Hand: [], + p1Points: [ Card.TEN_OF_DIAMONDS ], + p1FaceCards: [], + deck: [], + }); + + // The deck is exhausted, so the only thing to do with it is pass + assertDeckCount(0); + cy.get('#empty-deck-text').should('be.visible'); + + // Player nines the opponent's ten, putting it back on the empty deck + 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(); - }); // End Nine triggers discard-to-hand-limit + // The deck is no longer empty, so passing is no longer on offer + assertDeckCount(1); + cy.get('#empty-deck-text').should('not.exist'); + + // The opponent has to spend their turn drawing their own ten back + cy.drawCardOpponent(); + assertDeckCount(0); + cy.get('#empty-deck-text').should('be.visible'); + + assertGameState(0, { + p0Hand: [], + p0Points: [], + p0FaceCards: [], + p1Hand: [ Card.TEN_OF_DIAMONDS ], + p1Points: [], + p1FaceCards: [], + deck: [], + }); + }); + }); // End empty deck describe }); diff --git a/tests/e2e/specs/in-game/one-offs/targeting_cleanup.spec.js b/tests/e2e/specs/in-game/one-offs/targeting_cleanup.spec.js index b1a9bad18..754229459 100644 --- a/tests/e2e/specs/in-game/one-offs/targeting_cleanup.spec.js +++ b/tests/e2e/specs/in-game/one-offs/targeting_cleanup.spec.js @@ -26,11 +26,12 @@ describe('Clean-up of One-Off Targets', () => { .get('[data-cy=cannot-counter-resolve]') .click(); + // The nine put the ace on top of the deck rather than back into the player's hand assertGameState(1, { p0Hand: [ Card.NINE_OF_HEARTS, Card.FIVE_OF_CLUBS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS, Card.ACE_OF_DIAMONDS ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], @@ -77,7 +78,7 @@ describe('Clean-up of One-Off Targets', () => { p0Hand: [ Card.NINE_OF_HEARTS, Card.FIVE_OF_CLUBS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], + p1Hand: [ Card.SIX_OF_HEARTS ], p1Points: [ Card.ACE_OF_DIAMONDS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], diff --git a/tests/unit/fixtures/gameStates/resolveNine.js b/tests/unit/fixtures/gameStates/resolveNine.js index 6f840a165..4c6e5800b 100644 --- a/tests/unit/fixtures/gameStates/resolveNine.js +++ b/tests/unit/fixtures/gameStates/resolveNine.js @@ -14,10 +14,10 @@ const gameStateRow = { p0Hand: [], p0Points: [ 'AH', 'TH' ], p0FaceCards: [], - p1Hand: [ 'JC' ], + p1Hand: [], p1Points: [], p1FaceCards: [], - deck: [ 'AC', '2C', '3C', '4C', '5C', '6C', '7C', '8C', 'TC', 'QC', 'KC', + deck: [ 'JC', 'AC', '2C', '3C', '4C', '5C', '6C', '7C', '8C', 'TC', 'QC', 'KC', 'AD', '2D', '3D', '4D', '5D', '6D', '7D', '8D', '9D', 'TD', 'JD', 'QD', 'KD', '2H', '3H', '4H', '5H', '6H', '7H', '8H', '9H', 'JH', 'QH', 'KH', 'AS', '2S', '3S', '4S', '5S', '6S', '7S', '8S', '9S', 'TS', 'JS', 'QS', 'KS' ], @@ -49,11 +49,12 @@ export const resolveNine = { faceCards: [], }, p1: { - hand: [ { ...Card.JACK_OF_CLUBS, isFrozen: true } ], + hand: [], points: [], faceCards: [], }, deck: [ + Card.JACK_OF_CLUBS, Card.ACE_OF_CLUBS, Card.TWO_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.TEN_OF_CLUBS, Card.QUEEN_OF_CLUBS, Card.KING_OF_CLUBS, @@ -89,6 +90,7 @@ export const resolveNine = { game: { chat: [], deck: [ + Card.JACK_OF_CLUBS, Card.ACE_OF_CLUBS, Card.TWO_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.TEN_OF_CLUBS, Card.QUEEN_OF_CLUBS, @@ -115,7 +117,7 @@ export const resolveNine = { lock: null, lockedAt: null, log: [ - 'The 9♣️ one-off resolves, returning the J♣️ to definitelyNotTheGovernment6969\'s hand. It cannot be played next turn.', + 'The 9♣️ one-off resolves, putting the J♣️ on top of the deck.', ], match: null, name: 'Test Game', @@ -143,7 +145,7 @@ export const resolveNine = { frozenId: null, }, { - hand: [ { ...Card.JACK_OF_CLUBS, isFrozen: true } ], + hand: [], points: [], faceCards: [], createdAt: '2024-07-14T12:59:47.709Z', diff --git a/utils/rulesVersion.js b/utils/rulesVersion.js index b29a83cee..f7e34f25c 100644 --- a/utils/rulesVersion.js +++ b/utils/rulesVersion.js @@ -23,7 +23,8 @@ * 1.0.0 (#1197) => GameState API release * 1.0.1 (#1320) => Cannot Three for a Three * 1.0.2 (#1359) => Hand limit rework: discard down to 8 at end of your turn + * 2.0.0 (#TBD) => Nine rework: put target card on top of the deck instead of returning it frozen to hand */ module.exports = { - CURRENT_RULES_VERSION: '1.0.2', + CURRENT_RULES_VERSION: '2.0.0', }; From 92def2325ac53f5f9e3e121f7c7355e51eab300d Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Thu, 17 Sep 2026 07:36:53 -0400 Subject: [PATCH 02/12] docs(rulesVersion): reference PR number in 2.0.0 history line Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- utils/rulesVersion.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/rulesVersion.js b/utils/rulesVersion.js index f7e34f25c..5026b1eee 100644 --- a/utils/rulesVersion.js +++ b/utils/rulesVersion.js @@ -23,7 +23,7 @@ * 1.0.0 (#1197) => GameState API release * 1.0.1 (#1320) => Cannot Three for a Three * 1.0.2 (#1359) => Hand limit rework: discard down to 8 at end of your turn - * 2.0.0 (#TBD) => Nine rework: put target card on top of the deck instead of returning it frozen to hand + * 2.0.0 (#1379) => Nine rework: put target card on top of the deck instead of returning it frozen to hand */ module.exports = { CURRENT_RULES_VERSION: '2.0.0', From 19de2d55e2651f155a06f1de362d472a5b81c788 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Thu, 17 Sep 2026 20:56:19 -0400 Subject: [PATCH 03/12] refactor(nines): address review on topdeck nines - is-top-jack: collapse find-then-compare into a single `some` with optional chaining, per review suggestion. - Restrict jack targeting to the top of the stack for twos as well as nines, and rename the message key to game.snackbar.oneOffs.onlyTopJack since it is no longer nine-specific. Twos already removed the exact targeted jack, but each jack's owner is derived from its position in the stack (convert-card-to-str.js infers it from attachment parity), so destroying a buried jack silently reassigns every jack above it and can flip control of the point card. The UI already offered only the top jack for both ranks; this closes the socket-level hole. - resolve/nine.js: lean on that validator guarantee and pop the top attachment instead of re-finding it by id. - Announcement: frame the change as the first of four rules variations trialling over the Spades season (two Nine buffs, two Four buffs) and link the Patreon post explaining all four. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- api/helpers/game-states/is-top-jack.js | 6 ++---- api/helpers/game-states/moves/one-off/validate.js | 7 ++++--- api/helpers/game-states/moves/resolve/nine.js | 6 +++--- api/helpers/game-states/moves/seven-one-off/validate.js | 7 ++++--- .../announcementDialog/data/announcementData.js | 8 +++++++- src/translations/de.json | 9 ++++----- src/translations/en.json | 9 ++++----- src/translations/es.json | 9 ++++----- src/translations/fr.json | 9 ++++----- src/translations/ukr.json | 9 ++++----- 10 files changed, 40 insertions(+), 39 deletions(-) diff --git a/api/helpers/game-states/is-top-jack.js b/api/helpers/game-states/is-top-jack.js index 2893ac757..dcfe29940 100644 --- a/api/helpers/game-states/is-top-jack.js +++ b/api/helpers/game-states/is-top-jack.js @@ -4,7 +4,7 @@ module.exports = { description: 'Determines whether the specified jack is the top of the stack attached to the point card it is stealing', extendedDescription: - 'Only the top jack of a stack may be targeted by a nine, since removing a buried jack would leave the remaining stack in an order that never occurred.', + 'Only the top jack of a stack may be targeted. Each jack\'s owner is derived from its position in the stack (see convert-card-to-str.js), so removing a buried jack silently reassigns every jack above it -- and with it, control of the point card.', inputs: { targetId: { @@ -25,8 +25,6 @@ module.exports = { }, sync: true, fn: ({ targetId, player }, exits) => { - const host = player.points.find(({ attachments }) => attachments.some(({ id }) => id === targetId)); - - return exits.success(host?.attachments.at(-1).id === targetId); + return exits.success(player.points.some(({ attachments }) => attachments.at(-1)?.id === targetId)); }, }; diff --git a/api/helpers/game-states/moves/one-off/validate.js b/api/helpers/game-states/moves/one-off/validate.js index 02b057be1..e1cb41517 100644 --- a/api/helpers/game-states/moves/one-off/validate.js +++ b/api/helpers/game-states/moves/one-off/validate.js @@ -86,13 +86,14 @@ module.exports = { throw new BadRequestError('Twos can only target royals or glasses'); } - // Nines put the target on top of the deck, so a buried jack is never a legal target + // Only the top attachment can be targeted. Each jack's owner is derived from its + // position in the stack, so removing a buried one silently reassigns the jacks above + // it -- and with them, control of the point card. if ( - playedCard.rank === 9 && requestedMove.targetType === 'jack' && !sails.helpers.gameStates.isTopJack(requestedMove.targetId, opponent) ) { - throw new BadRequestError('game.snackbar.oneOffs.nine.onlyTopJack'); + throw new BadRequestError('game.snackbar.oneOffs.onlyTopJack'); } const queenCount = opponent.faceCards.filter((faceCard) => faceCard.rank === 12).length; diff --git a/api/helpers/game-states/moves/resolve/nine.js b/api/helpers/game-states/moves/resolve/nine.js index 74168779b..f0296e12f 100644 --- a/api/helpers/game-states/moves/resolve/nine.js +++ b/api/helpers/game-states/moves/resolve/nine.js @@ -52,9 +52,9 @@ module.exports = { } const [ targetCard ] = opponent.points.splice(targetIndex, 1); - // Remove the targeted jack, which the validator guarantees is the top of the stack - const jackIndex = targetCard.attachments.findIndex(({ id }) => id === result.oneOffTarget.id); - const [ jack ] = targetCard.attachments.splice(jackIndex, 1); + // Both one-off validators reject any jack target that isn't the top of its stack, + // so the targeted jack is the last attachment + const jack = targetCard.attachments.pop(); // Card the jack was stealing goes back to other player player.points.push( diff --git a/api/helpers/game-states/moves/seven-one-off/validate.js b/api/helpers/game-states/moves/seven-one-off/validate.js index 5b4e15c91..ec73f2280 100644 --- a/api/helpers/game-states/moves/seven-one-off/validate.js +++ b/api/helpers/game-states/moves/seven-one-off/validate.js @@ -76,13 +76,14 @@ module.exports = { throw new BadRequestError('Twos can only target royals or glasses'); } - // Nines put the target on top of the deck, so a buried jack is never a legal target + // Only the top attachment can be targeted. Each jack's owner is derived from its + // position in the stack, so removing a buried one silently reassigns the jacks above + // it -- and with them, control of the point card. if ( - playedCard.rank === 9 && requestedMove.targetType === 'jack' && !sails.helpers.gameStates.isTopJack(requestedMove.targetId, opponent) ) { - throw new BadRequestError('game.snackbar.oneOffs.nine.onlyTopJack'); + throw new BadRequestError('game.snackbar.oneOffs.onlyTopJack'); } const queenCount = opponent.faceCards.filter((faceCard) => faceCard.rank === 12).length; diff --git a/src/routes/home/components/announcementDialog/data/announcementData.js b/src/routes/home/components/announcementDialog/data/announcementData.js index 45e25cd9d..a5ddcae4b 100644 --- a/src/routes/home/components/announcementDialog/data/announcementData.js +++ b/src/routes/home/components/announcementDialog/data/announcementData.js @@ -17,7 +17,13 @@ export const announcementData = { }, { heading: 'announcement.heading2', - paragraph: [ { text: 'announcement.paragraph2' } ], + paragraph: [ + { text: 'announcement.paragraph2' }, + { + text: 'announcement.patreonLink', + url: 'https://www.patreon.com/cuttle/posts/help-refine-4-9-169762967', + }, + ], }, ], }; diff --git a/src/translations/de.json b/src/translations/de.json index af682fbef..2b8915763 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -363,9 +363,7 @@ "mustBeDoubleJacks": "Du kannst keine Karten von einer Sieben abwerfen, es sei denn, beide Karten sind Buben", "jacksMustBeUnplayable": "Du kannst nicht abwerfen, weil die Buben gespielt werden können" }, - "nine": { - "onlyTopJack": "Du kannst nur den obersten Buben eines Stapels als Ziel wählen" - } + "onlyTopJack": "Du kannst nur den obersten Buben eines Stapels als Ziel wählen" } } }, @@ -593,8 +591,9 @@ "title": "Regel-Beta: Neuner legen obenauf", "heading": "Neuner legen Karten jetzt oben auf den Nachziehstapel", "paragraph": "Ein Neuner ist eine der höchsten Karten im Spiel, doch sein Einmal-Effekt war schon immer der speziellste. Statt eine Karte auf die Hand ihres Besitzers zurückzugeben, legt ein Neuner jetzt eine Karte vom Spielfeld deines Gegners oben auf den Nachziehstapel — eine Punktekarte, eine Royal oder den obersten Buben eines Stapels. Ihr nächster Zug bringt ihnen die eigene Karte zurück, der Neuner kostet sie also einen ganzen Zug. Nichts wird mehr eingefroren, und Damen schützen weiterhin wie bisher.", - "heading2": "Test während der Pik-Saison", - "paragraph2": "Das ist eine Beta. Wir lassen sie die gesamte Pik-Saison über laufen, damit alle sie in echten Partien ausprobieren können, und stellen sie dann gegen Ende des Jahres in einer Community-Umfrage zur Abstimmung." + "heading2": "Die erste von vier Testregeln der Pik-Saison", + "paragraph2": "Das ist eine Beta und die erste von vier Regelvarianten, die wir in der Pik-Saison testen — zwei Verbesserungen für Neuner und zwei für Vierer. Gegen Ende des Jahres stellen wir sie in einer Community-Umfrage zur Abstimmung. Den vollständigen Hintergrund zu allen vier findest du unter", + "patreonLink": "Die 4 und die 9 verbessern" }, "profile": { "myGames": "Meine Spiele", diff --git a/src/translations/en.json b/src/translations/en.json index 353779aa4..ee2cb49a0 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -364,9 +364,7 @@ "mustBeDoubleJacks": "You can't discard from a seven unless both cards are jacks", "jacksMustBeUnplayable": "You can't discard because the jacks can be played" }, - "nine": { - "onlyTopJack": "You can only target the top Jack of a stack" - } + "onlyTopJack": "You can only target the top Jack of a stack" } } }, @@ -594,8 +592,9 @@ "title": "Rules Beta: Topdeck Nines", "heading": "Nines now put cards on top of the deck", "paragraph": "A Nine is one of the highest cards in the deck, but its one-off has always been the most niche. Now, instead of returning a card to its controller's hand, a Nine one-off puts a card from your opponent's board on top of the deck — a point card, a Royal, or the top Jack of a stack. Their next draw is their own card coming back, so the Nine costs them a whole turn. Nothing is frozen any more, and Queens still protect as before.", - "heading2": "Testing through the Spades season", - "paragraph2": "This is a beta. We'll run it for the whole Spades season so everyone can feel it out in real games, then put it to a community poll toward the end of the year to decide whether it stays." + "heading2": "The first of four Spades season trials", + "paragraph2": "This is a beta, and the first of four rules variations we're trialling over the Spades season — two Nine buffs and two Four buffs. We'll put them to a community poll toward the end of the year to decide what stays. For the full context and reasoning behind all four, see", + "patreonLink": "Help refine the 4 and the 9" }, "profile": { "myGames": "My Games", diff --git a/src/translations/es.json b/src/translations/es.json index 86fb74ef5..2718ed9bf 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -363,9 +363,7 @@ "mustBeDoubleJacks": "No puedes descartar desde un siete a menos que ambas cartas sean jotas", "jacksMustBeUnplayable": "No puedes descartar porque las jotas se pueden jugar" }, - "nine": { - "onlyTopJack": "Solo puedes seleccionar la Jota superior de una pila" - } + "onlyTopJack": "Solo puedes seleccionar la Jota superior de una pila" } } }, @@ -593,8 +591,9 @@ "title": "Beta de reglas: Nueves al mazo", "heading": "Los Nueves ahora ponen cartas encima del mazo", "paragraph": "El Nueve es una de las cartas más altas de la baraja, pero su efecto único siempre ha sido el más específico. Ahora, en lugar de devolver una carta a la mano de su controlador, un Nueve pone una carta del campo de tu rival encima del mazo: una carta de puntos, una Figura o la Jota superior de una pila. Su siguiente robo será su propia carta, así que el Nueve les cuesta un turno entero. Ya no se congela nada, y las Damas siguen protegiendo igual que antes.", - "heading2": "En pruebas durante la temporada de Picas", - "paragraph2": "Esto es una beta. La mantendremos durante toda la temporada de Picas para que todo el mundo pueda probarla en partidas reales y después la someteremos a una votación de la comunidad hacia final de año para decidir si se queda." + "heading2": "La primera de cuatro pruebas de la temporada de Picas", + "paragraph2": "Esto es una beta, y la primera de cuatro variantes de reglas que probaremos durante la temporada de Picas: dos mejoras para el Nueve y dos para el Cuatro. Las someteremos a una votación de la comunidad hacia final de año para decidir qué se queda. Para conocer el contexto completo y el razonamiento de las cuatro, consulta", + "patreonLink": "Ayuda a refinar el 4 y el 9" }, "profile": { "myGames": "Mis juegos", diff --git a/src/translations/fr.json b/src/translations/fr.json index 5c7811a05..8d4bd0d5c 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -363,9 +363,7 @@ "mustBeDoubleJacks": "Vous ne pouvez pas défausser depuis un sept sauf si les deux cartes sont des valets", "jacksMustBeUnplayable": "Vous ne pouvez pas défausser parce que les valets peuvent être joués" }, - "nine": { - "onlyTopJack": "Vous ne pouvez cibler que le Valet du dessus d'une pile" - } + "onlyTopJack": "Vous ne pouvez cibler que le Valet du dessus d'une pile" } } }, @@ -593,8 +591,9 @@ "title": "Bêta des règles : Neufs sur la pioche", "heading": "Les Neufs placent désormais les cartes sur la pioche", "paragraph": "Le Neuf est l'une des plus fortes cartes du jeu, mais son effet unique a toujours été le plus limité. Désormais, au lieu de renvoyer une carte dans la main de son contrôleur, un Neuf place une carte du terrain de votre adversaire sur le dessus de la pioche : une carte de points, une figure ou le Valet du dessus d'une pile. Sa prochaine pioche lui rendra sa propre carte, le Neuf lui coûte donc un tour entier. Plus rien n'est gelé, et les Dames protègent toujours comme avant.", - "heading2": "Test pendant la saison des Piques", - "paragraph2": "Il s'agit d'une bêta. Nous la laisserons en place pendant toute la saison des Piques afin que chacun puisse l'essayer en partie réelle, puis nous la soumettrons à un vote de la communauté vers la fin de l'année pour décider si elle reste." + "heading2": "La première de quatre expérimentations de la saison des Piques", + "paragraph2": "Il s'agit d'une bêta, et de la première de quatre variantes de règles que nous testerons pendant la saison des Piques : deux améliorations du Neuf et deux du Quatre. Nous les soumettrons à un vote de la communauté vers la fin de l'année pour décider de ce qui reste. Pour le contexte complet et le raisonnement derrière les quatre, voir", + "patreonLink": "Aidez-nous à affiner le 4 et le 9" }, "profile": { "myGames": "Mes jeux", diff --git a/src/translations/ukr.json b/src/translations/ukr.json index 9db13aa83..23cbecfe5 100644 --- a/src/translations/ukr.json +++ b/src/translations/ukr.json @@ -363,9 +363,7 @@ "mustBeDoubleJacks": "Не можна відкидати з сімки, якщо обидві картки не є валетами", "jacksMustBeUnplayable": "Не можна відкидати, тому що валети можуть бути зіграні" }, - "nine": { - "onlyTopJack": "Ви можете вибрати лише верхнього Валета у стосі" - } + "onlyTopJack": "Ви можете вибрати лише верхнього Валета у стосі" } } }, @@ -593,8 +591,9 @@ "title": "Бета правил: дев'ятки кладуть на колоду", "heading": "Дев'ятки тепер кладуть карти зверху колоди", "paragraph": "Дев'ятка — одна з найстарших карт у колоді, але її одноразовий ефект завжди був найвужчим. Тепер замість повернення карти в руку власника дев'ятка кладе карту з поля вашого суперника зверху колоди — карту очок, фігуру або верхнього Валета у стосі. Наступним ходом вони візьмуть свою ж карту, тож дев'ятка коштує їм цілого ходу. Більше нічого не заморожується, а Дами захищають як і раніше.", - "heading2": "Тестуємо впродовж сезону Пік", - "paragraph2": "Це бета-версія. Ми залишимо її на весь сезон Пік, щоб усі встигли спробувати її в справжніх партіях, а наприкінці року проведемо опитування спільноти, чи залишати її." + "heading2": "Перша з чотирьох проб сезону Пік", + "paragraph2": "Це бета-версія і перша з чотирьох варіацій правил, які ми тестуємо впродовж сезону Пік — два посилення дев'ятки та два четвірки. Наприкінці року проведемо опитування спільноти, щоб вирішити, що залишити. Повний контекст і обґрунтування всіх чотирьох див. тут:", + "patreonLink": "Допоможіть удосконалити 4 та 9" }, "profile": { "myGames": "Мої ігри", From 9e365c30330acb2cbf5898d219eefe739287d63c Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Fri, 18 Sep 2026 07:08:26 -0400 Subject: [PATCH 04/12] chore: comment validate.js --- api/helpers/game-states/moves/seven-one-off/validate.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/helpers/game-states/moves/seven-one-off/validate.js b/api/helpers/game-states/moves/seven-one-off/validate.js index ec73f2280..219b3006e 100644 --- a/api/helpers/game-states/moves/seven-one-off/validate.js +++ b/api/helpers/game-states/moves/seven-one-off/validate.js @@ -76,9 +76,7 @@ module.exports = { throw new BadRequestError('Twos can only target royals or glasses'); } - // Only the top attachment can be targeted. Each jack's owner is derived from its - // position in the stack, so removing a buried one silently reassigns the jacks above - // it -- and with them, control of the point card. + // Only the top attachment can be targeted if ( requestedMove.targetType === 'jack' && !sails.helpers.gameStates.isTopJack(requestedMove.targetId, opponent) From 50f75972fc3670f1132a36cdf0f0836637af866b Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Fri, 18 Sep 2026 07:11:51 -0400 Subject: [PATCH 05/12] feat(nines): animate the topdeck as a flip then a move to the deck Adds processNines, a three-stage store action in the shape of processThrees: 1. the target flips face down where it sits (1000ms, matching CARD_FLIP's --duration-slow) 2. it leaves the field toward the deck, and the deck count grows 3. updateGame applies the server state It has to be staged rather than done in one render. A single rank-9 resolve can both remove a topdecked point card from the opponent's points (which should travel toward the deck) and hand a jacked point card back to its owner (which should travel toward that owner). A TransitionGroup has one name per render, so those two leave directions cannot coexist. Splitting them across renders gives each the right one, and avoids having to persist the target's type through resolve just to tell them apart. The flip needs no GameCard change: withholding suit and rank satisfies its existing isBack check, and the CARD_FLIP transition already wrapping those images does the rotation. Data attributes keep using the real card so test selectors stay stable. Direction depends on where the deck sits relative to the card, via toDeckTransition(side, area): xs points -> down, faceCards -> down and left above xs player -> up and left, opponent -> straight left Jacks follow their own point row, which meant wrapping .jacks-container in a TransitionGroup -- it was a plain div, so jacks never animated. Also adds an optional deckLength to assertGameState, asserted against both the store and the deck badge, and threads it through the nines fixtures. Deck contents stay unasserted on purpose: hideDeck() replaces every deck card with { isHidden: true } outside a seven, so a player cannot see them. The specs prove top-of-deck placement by drawing the card back instead, since a draw is deck.shift(). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- src/plugins/sockets/inGameEvents.js | 2 + src/routes/game/GameView.vue | 79 ++++++++++++++---- src/sass/transitions.scss | 43 ++++++++++ src/stores/game.js | 66 +++++++++++++++ .../specs/in-game/one-offs/9_nines.spec.js | 83 ++++++++++++++++--- tests/e2e/support/helpers.js | 18 ++++ utils/Transitions.json | 6 +- 7 files changed, 265 insertions(+), 32 deletions(-) diff --git a/src/plugins/sockets/inGameEvents.js b/src/plugins/sockets/inGameEvents.js index e77eee648..08a1a19d2 100644 --- a/src/plugins/sockets/inGameEvents.js +++ b/src/plugins/sockets/inGameEvents.js @@ -64,6 +64,8 @@ export async function handleInGameEvents(evData, newRoute = null) { case SocketEvent.RESOLVE: if (evData.game.lastEvent?.oneOff?.rank === 7) { await gameStore.processSevens(evData.game); + } else if (evData.game.lastEvent?.oneOff?.rank === 9) { + await gameStore.processNines(evData.targetCard, evData.game); } else { gameStore.updateGame(evData.game); } diff --git a/src/routes/game/GameView.vue b/src/routes/game/GameView.vue index b7a84ac54..7adec0415 100644 --- a/src/routes/game/GameView.vue +++ b/src/routes/game/GameView.vue @@ -222,35 +222,35 @@ class="field-point-container" > -
+ -
+ -
+ -
+
@@ -542,7 +542,30 @@ export default { /////////////////////////// // Transition Directions // /////////////////////////// + /** + * Which way "toward the deck" is, for a card leaving the field during a nine's topdeck. + * At xs the deck sits below the field; above xs it sits to the upper left, so a card on + * the player's half travels up and left while one on the opponent's half goes straight + * across. Jacks follow their own point row. + */ + toDeckTransition() { + return (side, area) => { + if (this.$vuetify.display.xs) { + return area === 'points' ? Transitions.TO_DECK_DOWN : Transitions.TO_DECK_DOWN_LEFT; + } + return side === 'player' ? Transitions.TO_DECK_UP_LEFT : Transitions.TO_DECK_LEFT; + }; + }, + isTopdecking() { + return this.gameStore.topdeckedCard !== null; + }, + topdeckedCardId() { + return this.gameStore.topdeckedCard?.id ?? null; + }, playerPointsTransition() { + if (this.isTopdecking) { + return this.toDeckTransition('player', 'points'); + } switch (this.gameStore.lastEventChange) { case 'resolve': // Different one-offs cause points to move in different directions @@ -568,6 +591,9 @@ export default { } }, playerFaceCardsTransition() { + if (this.isTopdecking) { + return this.toDeckTransition('player', 'faceCards'); + } // Playing from the deck if (this.gameStore.lastEventChange === 'sevenFaceCard') { return this.$vuetify.display.xs ? Transitions.SLIDE_DOWN : Transitions.ENTER_FROM_UPPER_LEFT; @@ -577,6 +603,9 @@ export default { return Transitions.SLIDE_DOWN_LEFT; }, opponentPointsTransition() { + if (this.isTopdecking) { + return this.toDeckTransition('opponent', 'points'); + } switch (this.gameStore.lastEventChange) { // Jacks cause point cards to switch control (from/towards player) case 'jack': @@ -604,6 +633,9 @@ export default { } }, opponentFaceCardsTransition() { + if (this.isTopdecking) { + return this.toDeckTransition('opponent', 'faceCards'); + } // Playing from the deck if (this.gameStore.lastEventChange === 'sevenFaceCard') { return this.$vuetify.display.xs ? Transitions.SLIDE_DOWN : Transitions.ENTER_FROM_LEFT; @@ -738,6 +770,17 @@ export default { this.snackbarStore.alert(this.t(messageKey)); this.clearSelection(); }, + isTopdeckedCard(card) { + return card.id === this.topdeckedCardId; + }, + /** + * Withholds suit and rank from the card a nine is topdecking, which is all GameCard needs + * to render its back (see its isBack check) and flips it via the existing CARD_FLIP + * transition. Data attributes keep using the real card so selectors stay stable. + */ + faceDownWhenTopdecked(card) { + return this.isTopdeckedCard(card) ? { suit: undefined, rank: undefined } : card; + }, showCustomSnackbarMessage(messageKey) { this.snackbarStore.alert(this.t(messageKey), 'base-dark'); }, diff --git a/src/sass/transitions.scss b/src/sass/transitions.scss index a3ad3ca87..758256cf7 100644 --- a/src/sass/transitions.scss +++ b/src/sass/transitions.scss @@ -138,3 +138,46 @@ from { rotate: 8deg; } to { rotate: 0deg; } } + +// Nine one-off: the targeted card flips face down (CARD_FLIP, via GameCard's isBack) +// and then travels off toward the deck. Which way "toward the deck" is depends on the +// breakpoint and which half of the field the card sits on -- see toDeckTransition() in +// GameView. Leave-only: nothing enters a row while a card is being topdecked out of it. +.to-deck-down-leave-active, +.to-deck-down-left-leave-active, +.to-deck-up-left-leave-active, +.to-deck-left-leave-active { + position: absolute; + transition: opacity var(--duration-slow), transform var(--duration-slow); +} + +.to-deck-down-move, +.to-deck-down-left-move, +.to-deck-up-left-move, +.to-deck-left-move { + transition: transform var(--duration-slow); +} + +// xs: the deck sits below the point rows +.to-deck-down-leave-to { + opacity: 0; + transform: translateY(96px); +} + +// xs: and below/left of the face card rows +.to-deck-down-left-leave-to { + opacity: 0; + transform: translate(-96px, 96px); +} + +// Above xs: the deck is up and to the left of the player's half +.to-deck-up-left-leave-to { + opacity: 0; + transform: translate(-96px, -96px); +} + +// Above xs: and straight left of the opponent's half +.to-deck-left-leave-to { + opacity: 0; + transform: translateX(-96px); +} diff --git a/src/stores/game.js b/src/stores/game.js index 71b0151c7..95c898e20 100644 --- a/src/stores/game.js +++ b/src/stores/game.js @@ -104,6 +104,9 @@ export const useGameStore = defineStore('game', () => { // Last Event const lastEventChange = ref(null); const lastEventOneOffRank = ref(null); + // Set only while a nine's topdeck animation is in flight. Drives both the face-down flip + // (GameCard renders its back when suit/rank are withheld) and the toward-the-deck exit. + const topdeckedCard = ref(null); const lastEventPlayedBy = ref(null); // GameOver const gameIsOver = ref(false); @@ -308,6 +311,7 @@ export const useGameStore = defineStore('game', () => { lastEventPlayerChoosing.value = false; lastEventChange.value = null; lastEventOneOffRank.value = null; + topdeckedCard.value = null; lastEventPlayedBy.value = null; lastEventThreeTarget.value = null; gameIsOver.value = false; @@ -371,6 +375,66 @@ export const useGameStore = defineStore('game', () => { await sleep(1000); updateGame(game); } + /** + * Animates a nine putting its target on top of the deck, in three stages so that each + * render has only one kind of change in it. + * + * It has to be staged: a nine can both remove a card from the opponent's points (which + * should travel toward the deck) and hand a jacked point card back to its owner (which + * should travel toward that owner). A TransitionGroup has a single name per render, so + * those two leave directions cannot coexist. Separating them into different renders lets + * each use the right one, and avoids having to persist the target's type through resolve. + */ + async function processNines(targetCard, game) { + if (!targetCard || !player.value) { + updateGame(game); + return; + } + + // Stage 1: the target flips face down where it sits. Matches CARD_FLIP's --duration-slow + // so the flip finishes before the card starts moving. + topdeckedCard.value = targetCard; + await sleep(1000); + + // Stage 2: it leaves the board toward the deck, and the deck grows to match + removeCardFromField(targetCard.id); + deck.value.unshift({ isHidden: true }); + await sleep(1000); + + // Stage 3: authoritative state. A topdecked jack returns its point card here, which is + // why this is a separate render from the exit above. + topdeckedCard.value = null; + updateGame(game); + } + + /** + * Removes a card from either player's points or face cards, including jacks attached to a + * point card. Used to play a card's exit animation before the server state is applied. + */ + function removeCardFromField(cardId) { + players.value.forEach((fieldPlayer) => { + if (!fieldPlayer) { + return; + } + const faceCardIndex = fieldPlayer.faceCards?.findIndex(({ id }) => id === cardId) ?? -1; + if (faceCardIndex > -1) { + fieldPlayer.faceCards.splice(faceCardIndex, 1); + } + + const pointIndex = fieldPlayer.points?.findIndex(({ id }) => id === cardId) ?? -1; + if (pointIndex > -1) { + fieldPlayer.points.splice(pointIndex, 1); + return; + } + + fieldPlayer.points?.forEach((pointCard) => { + const jackIndex = pointCard.attachments?.findIndex(({ id }) => id === cardId) ?? -1; + if (jackIndex > -1) { + pointCard.attachments.splice(jackIndex, 1); + } + }); + }); + } async function processThrees(chosenCard, game) { phase.value = GamePhase.MAIN; const three = game.resolved; @@ -734,6 +798,7 @@ export const useGameStore = defineStore('game', () => { lastEventThreeTarget, lastEventChange, lastEventOneOffRank, + topdeckedCard, lastEventPlayedBy, gameIsOver, winnerPNum, @@ -787,6 +852,7 @@ export const useGameStore = defineStore('game', () => { setGameOver, setRematch, processScuttle, + processNines, processThrees, processFours, processFives, 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 c0ba29cfb..3c1f6cae1 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 @@ -71,8 +71,8 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], + deckLength: 47, }); - assertDeckCount(47); // The opponent's next draw is the card they just lost, proving it was on top cy.drawCardOpponent(); @@ -136,8 +136,8 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_HEARTS ], + deckLength: 47, }); - assertDeckCount(47); }); // End 9 one-off high-point card it('Plays a nine as a ONE-OFF to put a face card on top of the deck', () => { @@ -169,8 +169,8 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], + deckLength: 47, }); - assertDeckCount(47); }); // End 9 on face card it('Plays a 9 on a jack to steal back point card, topdecking the jack', () => { @@ -229,8 +229,8 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS ], + deckLength: 49, }); - assertDeckCount(49); // Should no longer see jack of clubs on screen cy.get('[data-player-face-card=11-0]').should('not.exist'); @@ -320,8 +320,8 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS ], + deckLength: 46, }); - assertDeckCount(46); // Opponent nines the jack that is now on top of the player's ace cy.playTargetedOneOffOpponent(Card.NINE_OF_HEARTS, Card.JACK_OF_SPADES, 'jack'); @@ -335,8 +335,8 @@ describe('Playing NINES', () => { p1Points: [ Card.ACE_OF_SPADES ], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS, Card.NINE_OF_HEARTS ], + deckLength: 47, }); - assertDeckCount(47); }); it('Plays a 9 on a point card that has been triple jacked', () => { @@ -384,8 +384,8 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [], scrap: [ Card.JACK_OF_DIAMONDS, Card.JACK_OF_CLUBS, Card.JACK_OF_SPADES, Card.NINE_OF_CLUBS ], + deckLength: 46, }); - assertDeckCount(46); // Opponent draws the ace back, and the player draws the card beneath it cy.drawCardOpponent(); @@ -432,8 +432,8 @@ describe('Playing NINES', () => { p1Points: [ Card.ACE_OF_DIAMONDS ], p1FaceCards: [], scrap: [], + deckLength: 46, }); - assertDeckCount(46); }); it('Plays a nine as a ONE-OFF; the topdecked card is drawn back and replayed immediately', () => { @@ -475,8 +475,8 @@ describe('Playing NINES', () => { p1Points: [ Card.TEN_OF_HEARTS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], + deckLength: 46, }); - assertDeckCount(46); // STEP 2 cy.log('STEP 2- P1 plays a six, removing face cards'); @@ -571,8 +571,8 @@ describe('Playing NINES', () => { p1Points: [ Card.TEN_OF_HEARTS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], + deckLength: 45, }); - assertDeckCount(45); // STEP 2 cy.log('STEP 2- P1 tries to play a six'); @@ -750,8 +750,8 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS ], + deckLength: 47, }); - assertDeckCount(47); cy.get('[data-player-hand-card=10-1]').click(); cy.get('[data-move-choice=points]').click(); @@ -904,8 +904,8 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], + deckLength: 43, }); - assertDeckCount(43); cy.get('#discard-to-hand-limit-dialog').should('not.exist'); }); }); // End Opponent playing NINES describe @@ -959,8 +959,8 @@ describe('Playing NINES', () => { p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], + deckLength: 43, }); - assertDeckCount(43); cy.get('#discard-to-hand-limit-dialog').should('not.exist'); }); }); // End Nine does not trigger discard-to-hand-limit @@ -1012,4 +1012,61 @@ describe('Playing NINES', () => { }); }); }); // End empty deck describe + + describe('Topdeck animation', () => { + const fixture = { + p0Hand: [ Card.NINE_OF_SPADES ], + p0Points: [ Card.TEN_OF_HEARTS ], + p0FaceCards: [], + p1Hand: [], + p1Points: [ Card.ACE_OF_DIAMONDS ], + p1FaceCards: [], + }; + + function topdeckTheAce() { + cy.get('[data-player-hand-card=9-3]').click(); + cy.get('[data-move-choice=targetedOneOff]').click(); + cy.get('[data-opponent-point-card=1-1]').click(); + cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); + cy.resolveOpponent(); + } + + beforeEach(() => { + cy.setupGameAsP0(); + }); + + it('Flips the target face down, then sends it left toward the deck', () => { + cy.viewport(1920, 1080); + cy.loadGameFixture(0, fixture); + topdeckTheAce(); + + // Stage one: still in place, but now showing its back + cy.get('[data-opponent-point-card=1-1] img.opponent-card-back').should('exist'); + // Stage two: leaves toward the deck, which above xs sits to the left + cy.get('.to-deck-left-leave-active').should('exist'); + // Stage three: gone, and the deck has grown + cy.get('[data-opponent-point-card=1-1]').should('not.exist'); + + assertGameState(0, { + p0Hand: [], + p0Points: [ Card.TEN_OF_HEARTS ], + p0FaceCards: [], + p1Hand: [], + p1Points: [], + p1FaceCards: [], + scrap: [ Card.NINE_OF_SPADES ], + deckLength: 50, + }); + }); + + it('Sends the target downward toward the deck on xs', () => { + cy.viewport('iphone-x'); + cy.loadGameFixture(0, fixture); + topdeckTheAce(); + + cy.get('[data-opponent-point-card=1-1] img.opponent-card-back').should('exist'); + cy.get('.to-deck-down-leave-active').should('exist'); + cy.get('[data-opponent-point-card=1-1]').should('not.exist'); + }); + }); // End topdeck animation describe }); diff --git a/tests/e2e/support/helpers.js b/tests/e2e/support/helpers.js index f12cea152..da33c05ef 100644 --- a/tests/e2e/support/helpers.js +++ b/tests/e2e/support/helpers.js @@ -303,6 +303,13 @@ function assertDomMatchesFixture(pNum, fixture, spectating) { if (fixture.scrap) { cy.get('#scrap').contains(`(${fixture.scrap.length})`); } + + // Test deck size (if provided) + if (typeof fixture.deckLength === 'number') { + cy.get('#deck .c-deck-count').invoke('text') + .invoke('trim') + .should('eq', `(${fixture.deckLength})`); + } } export function setupSeasons() { @@ -435,6 +442,16 @@ function assertStoreMatchesFixture(pNum, fixture, isSpectating = false) { ); } + // Deck size if specified. Only the size is assertable from a player's perspective: + // create-socket-events' hideDeck() replaces every deck card with { isHidden: true } + // except while resolving a seven, so the cards themselves are unknowable here. + if (typeof fixture.deckLength === 'number') { + expect(game.deck.length).to.eq( + fixture.deckLength, + `Expected deck to hold ${fixture.deckLength} cards, but it held ${game.deck.length}`, + ); + } + // Top Card if specified if (fixture.topCard) { expect(cardsMatch(game.topCard, fixture.topCard)).to.eq( @@ -702,6 +719,7 @@ export function rematchPlayerAsSpectator(userFixture, rematch = true) { * p1Hand: {suit: number, rank: number}[], * p1Points: {suit: number, rank: number}[], * p1FaceCards: {suit: number, rank: number}[], + * deckLength?: number - asserts how many cards remain in the deck * } * @param pNum: int [0, 1] */ diff --git a/utils/Transitions.json b/utils/Transitions.json index a334d0e61..f0b252ba5 100644 --- a/utils/Transitions.json +++ b/utils/Transitions.json @@ -5,5 +5,9 @@ "ENTER_FROM_UPPER_LEFT": "enter-from-upper-left", "ENTER_FROM_LEFT": "enter-from-left", "SLIDE_UP_DOWN": "slide-up-down", - "CARD_FLIP": "card-flip" + "CARD_FLIP": "card-flip", + "TO_DECK_DOWN": "to-deck-down", + "TO_DECK_DOWN_LEFT": "to-deck-down-left", + "TO_DECK_UP_LEFT": "to-deck-up-left", + "TO_DECK_LEFT": "to-deck-left" } From 358adb55ee96bcfe760b1735753688bb4d1aee43 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Fri, 18 Sep 2026 09:08:57 -0400 Subject: [PATCH 06/12] fix(nines): leave the countering phase before the topdeck animation processNines deferred updateGame to stage three, so the client kept the pre-resolve phase for the whole two second animation. GamePhase.COUNTERING drives waitingForOpponentToCounter, which meant the "waiting for opponent to counter" scrim stayed up and covered the flip and the card's trip to the deck -- it only cleared once the animation had already finished. processThrees, processFours and processFives all set phase to MAIN before animating for this reason; processNines now does the same. Caught while recording the playground clip: the animation was running correctly underneath the scrim the entire time. Also updates the existing 'Playing Nines' playground test to open on the settled board via markClipStart, pace the setup moves, and hold on the result so the flip and the move to the deck are both watchable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- src/stores/game.js | 5 ++++ .../specs/playground/videoPlayground.spec.js | 25 +++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/stores/game.js b/src/stores/game.js index 95c898e20..471654485 100644 --- a/src/stores/game.js +++ b/src/stores/game.js @@ -391,6 +391,11 @@ export const useGameStore = defineStore('game', () => { return; } + // Drop out of the countering phase up front, the way processThrees and processFives do. + // The authoritative state does not land until stage 3, so without this the "waiting for + // opponent to counter" scrim covers the whole animation. + phase.value = GamePhase.MAIN; + // Stage 1: the target flips face down where it sits. Matches CARD_FLIP's --duration-slow // so the flip finishes before the card starts moving. topdeckedCard.value = targetCard; diff --git a/tests/e2e/specs/playground/videoPlayground.spec.js b/tests/e2e/specs/playground/videoPlayground.spec.js index 341d20255..3a73d88c5 100644 --- a/tests/e2e/specs/playground/videoPlayground.spec.js +++ b/tests/e2e/specs/playground/videoPlayground.spec.js @@ -666,23 +666,38 @@ describe('Video Playground', () => { p1Points: [ Card.TEN_OF_CLUBS ], p1FaceCards: [], }); + markClipStart(); + cy.wait(1500); + + // START RECORDING // + // Player puts the Ten of Spades out for points cy.get('[data-player-hand-card]').should('have.length', 3); cy.get('[data-player-hand-card=10-3]').click(); + cy.wait(1000); cy.get('[data-move-choice=points]').click(); cy.get('#turn-indicator').contains('OPPONENT\'S TURN'); + cy.wait(1500); + + // Opponent steals it with a Jack cy.playJackOpponent(Card.JACK_OF_DIAMONDS, Card.TEN_OF_SPADES); - cy.wait(2000); cy.get('#turn-indicator').contains('YOUR TURN'); - - // START RECORDING HERE // + cy.wait(2500); + + // Player answers with a Nine on the Jack cy.get('[data-player-hand-card=9-0]').click(); - cy.wait(1000); + cy.wait(1200); cy.get('[data-move-choice=targetedOneOff]').click(); - cy.wait(1000); + cy.wait(1200); cy.get('[data-opponent-face-card=11-1]').click(); cy.wait(1000); cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); cy.resolveOpponent(); + + // The topdeck animation: the Jack flips face down and travels to the deck, and the + // Ten it was stealing returns to the player's points + cy.get('[data-opponent-face-card=11-1]').should('not.exist'); + cy.get('[data-player-point-card=10-3]').should('exist'); + cy.wait(3000); }); it('Loses to a jack steal then points for the win', () => { From 0b63d3dd6517904ed63f793cc0cb4f0dc705795d Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Fri, 18 Sep 2026 09:26:31 -0400 Subject: [PATCH 07/12] test(playground): add a nine-breaks-check recording scenario Companion to the existing 'Playing Nines' clip. The opponent plays a point card taking them to 20 of the 21 they need -- any point card wins for them next turn -- and the player answers with a nine, putting that card on top of the deck and dropping them back to 10. Shows the topdeck against a point card rather than a jack, and gives the effect a stake the board can display: the goal readout goes 20/21 before the nine and 10/21 after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- .../specs/playground/videoPlayground.spec.js | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/e2e/specs/playground/videoPlayground.spec.js b/tests/e2e/specs/playground/videoPlayground.spec.js index 3a73d88c5..b65fd8de7 100644 --- a/tests/e2e/specs/playground/videoPlayground.spec.js +++ b/tests/e2e/specs/playground/videoPlayground.spec.js @@ -700,6 +700,42 @@ describe('Video Playground', () => { cy.wait(3000); }); + it('Plays a Nine to break check', () => { + cy.loadGameFixture(0, { + p0Hand: [ Card.ACE_OF_SPADES, Card.NINE_OF_CLUBS ], + p0Points: [ Card.TEN_OF_HEARTS ], + p0FaceCards: [], + p1Hand: [ Card.TEN_OF_SPADES, Card.FIVE_OF_HEARTS ], + p1Points: [ Card.TEN_OF_CLUBS ], + p1FaceCards: [], + }); + markClipStart(); + cy.wait(1500); + + // START RECORDING // + // Player adds a point of their own + playerPointsWithDelay(Card.ACE_OF_SPADES); + + // Opponent goes to 20 of the 21 they need -- any point card now wins for them + opponentPointsWithDelay(Card.TEN_OF_SPADES); + cy.get('#turn-indicator').contains('YOUR TURN'); + cy.wait(2500); + + // Player answers with a Nine on the point card that put them in check + cy.get('[data-player-hand-card=9-0]').click(); + cy.wait(1200); + cy.get('[data-move-choice=targetedOneOff]').click(); + cy.wait(1200); + cy.get('[data-opponent-point-card=10-3]').click(); + cy.wait(1000); + cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); + cy.resolveOpponent(); + + // The Ten flips face down and travels to the deck, dropping the opponent back to 10 + cy.get('[data-opponent-point-card=10-3]').should('not.exist'); + cy.wait(3000); + }); + it('Loses to a jack steal then points for the win', () => { cy.loadGameFixture(0, { p0Hand: [ Card.TEN_OF_SPADES, Card.FIVE_OF_HEARTS ], From 0be2cce6f3c6a3eafee46541389960493b2579c7 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Fri, 18 Sep 2026 10:06:09 -0400 Subject: [PATCH 08/12] test(playground): add a two-scraps-jack recording scenario Mirrors the nine-on-jack clip with a two, so the two effects can be compared side by side: the player puts out a point card, the opponent steals it with a jack, and the player answers by scrapping that jack. Recorded to check how the jack exits. It does not animate at all -- see the note on the PR. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- .../specs/playground/videoPlayground.spec.js | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/e2e/specs/playground/videoPlayground.spec.js b/tests/e2e/specs/playground/videoPlayground.spec.js index b65fd8de7..8619a4167 100644 --- a/tests/e2e/specs/playground/videoPlayground.spec.js +++ b/tests/e2e/specs/playground/videoPlayground.spec.js @@ -412,6 +412,43 @@ describe('Video Playground', () => { cy.resolveOpponent(); }); + it('Two to scrap a jack', () => { + cy.loadGameFixture(0, { + p0Hand: [ Card.TEN_OF_SPADES, Card.TWO_OF_CLUBS ], + p0Points: [ Card.TEN_OF_HEARTS ], + p0FaceCards: [], + p1Hand: [ Card.JACK_OF_DIAMONDS, Card.FIVE_OF_HEARTS ], + p1Points: [ Card.TEN_OF_CLUBS ], + p1FaceCards: [], + }); + markClipStart(); + cy.wait(1500); + + // START RECORDING // + // Player puts the Ten of Spades out for points + playerPointsWithDelay(Card.TEN_OF_SPADES); + + // Opponent steals it with a Jack + cy.playJackOpponent(Card.JACK_OF_DIAMONDS, Card.TEN_OF_SPADES); + cy.get('#turn-indicator').contains('YOUR TURN'); + cy.wait(2500); + + // Player scraps the Jack with a Two + cy.get('[data-player-hand-card=2-0]').click(); + cy.wait(1200); + cy.get('[data-move-choice=targetedOneOff]').click(); + cy.wait(1200); + cy.get('[data-opponent-face-card=11-1]').click(); + cy.wait(1000); + cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); + cy.resolveOpponent(); + + // The Jack is scrapped and the Ten it was stealing returns to the player + cy.get('[data-opponent-face-card=11-1]').should('not.exist'); + cy.get('[data-player-point-card=10-3]').should('exist'); + cy.wait(3000); + }); + it('Plays a 2 to counter opponent one-off', () => { cy.loadGameFixture(0, { p0Hand: [ Card.TWO_OF_HEARTS, Card.SEVEN_OF_HEARTS ], From d08b2a015989a0acf441a400d964b8426504fdb9 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Fri, 18 Sep 2026 10:22:47 -0400 Subject: [PATCH 09/12] test(playground): add a nine-on-glasses-eight recording scenario Set up from the seat that owns the glasses, so the effect is visible on the recorded screen: the opponent's hand starts face up, the opponent topdecks the glasses with a nine, and the hand goes back to card backs. Also covers the branch where a glasses eight is the topdeck target. GameView suppresses is-glasses for the card being topdecked so it falls through to GameCard's isBack check and flips to a card back rather than holding the goggles art. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- .../specs/playground/videoPlayground.spec.js | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/e2e/specs/playground/videoPlayground.spec.js b/tests/e2e/specs/playground/videoPlayground.spec.js index 8619a4167..ca0b7b074 100644 --- a/tests/e2e/specs/playground/videoPlayground.spec.js +++ b/tests/e2e/specs/playground/videoPlayground.spec.js @@ -773,6 +773,39 @@ describe('Video Playground', () => { cy.wait(3000); }); + it('Opponent plays a Nine on the player\'s glasses eight', () => { + cy.loadGameFixture(0, { + p0Hand: [ Card.ACE_OF_SPADES ], + p0Points: [ Card.TEN_OF_HEARTS ], + p0FaceCards: [ Card.EIGHT_OF_HEARTS ], + p1Hand: [ Card.NINE_OF_CLUBS, Card.FIVE_OF_HEARTS, Card.KING_OF_SPADES ], + p1Points: [ Card.TEN_OF_CLUBS ], + p1FaceCards: [], + }); + markClipStart(); + + // The glasses eight has the opponent's hand face up + cy.get('[data-opponent-hand-card=9-0]').should('be.visible'); + cy.wait(2000); + + // START RECORDING // + // Player adds a point, handing over the turn + playerPointsWithDelay(Card.ACE_OF_SPADES); + + // Opponent answers by topdecking the glasses + cy.playTargetedOneOffOpponent(Card.NINE_OF_CLUBS, Card.EIGHT_OF_HEARTS, 'faceCard'); + cy.get('#cannot-counter-dialog').should('be.visible'); + cy.wait(2000); + cy.get('[data-cy=cannot-counter-resolve]').click(); + + // The glasses flip face down, travel up and left to the deck, and the opponent's + // hand goes back to card backs + cy.get('[data-player-face-card=8-2]').should('not.exist'); + cy.get('[data-opponent-hand-card=9-0]').should('not.exist'); + cy.get('[data-opponent-hand-card]').should('have.length', 2); + cy.wait(3000); + }); + it('Loses to a jack steal then points for the win', () => { cy.loadGameFixture(0, { p0Hand: [ Card.TEN_OF_SPADES, Card.FIVE_OF_HEARTS ], From e52a68b95068256c22f5319b919ed387db8b9e54 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Fri, 18 Sep 2026 18:35:26 -0400 Subject: [PATCH 10/12] refactor(nines): collapse the topdeck animation to two stages Review was right that the third stage was unnecessary, and that the conflict I cited to justify it was self-inflicted. The claim was that opponent points needs two leave directions at once: a topdecked point card toward the deck, and a topdecked jack's point card back to its owner. But those two cards leave through different TransitionGroups -- the jack through .jacks-container, the point card through .field-points. I had bound the jacks container to the point row's transition name, which is what made them share a direction and appear to conflict. Binding the jacks container to its own transition removes the conflict, so the exit and the knock-on move now share a render: 1. the target flips face down where it sits 2. the server state lands; the target leaves for the deck while a topdecked jack's point card heads back to its owner Each row now resolves its own direction from the zone the target sat in (playerPoints, opponentJacks, and so on), captured before the state lands. That is more precise than the previous blanket "is a topdeck running" check, which pointed every row at the deck and left the point card entering the player's row with no enter animation to play. Also drops removeCardFromField: the local board mutation only existed to give stage two something to animate, and the deck count now updates with the rest of the state instead of being nudged early. Other review fixes: - inGameEvents: switch on the one-off rank instead of chained if/else - GameView: inline the single-use topdeckedCardId - transitions.scss: generate the four to-deck directions from one @each rather than repeating six blocks. -leave-active and -move stay separate because only the former may be positioned absolutely. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- src/plugins/sockets/inGameEvents.js | 15 +++--- src/routes/game/GameView.vue | 40 +++++++++----- src/sass/transitions.scss | 64 ++++++++++------------ src/stores/game.js | 83 ++++++++++++++--------------- 4 files changed, 106 insertions(+), 96 deletions(-) diff --git a/src/plugins/sockets/inGameEvents.js b/src/plugins/sockets/inGameEvents.js index 08a1a19d2..52f126805 100644 --- a/src/plugins/sockets/inGameEvents.js +++ b/src/plugins/sockets/inGameEvents.js @@ -62,12 +62,15 @@ export async function handleInGameEvents(evData, newRoute = null) { gameStore.updateGame(evData.game); break; case SocketEvent.RESOLVE: - if (evData.game.lastEvent?.oneOff?.rank === 7) { - await gameStore.processSevens(evData.game); - } else if (evData.game.lastEvent?.oneOff?.rank === 9) { - await gameStore.processNines(evData.targetCard, evData.game); - } else { - gameStore.updateGame(evData.game); + switch (evData.game.lastEvent?.oneOff?.rank) { + case 7: + await gameStore.processSevens(evData.game); + break; + case 9: + await gameStore.processNines(evData.targetCard, evData.game); + break; + default: + gameStore.updateGame(evData.game); } break; case SocketEvent.SCUTTLE: diff --git a/src/routes/game/GameView.vue b/src/routes/game/GameView.vue index 7adec0415..4ab38e529 100644 --- a/src/routes/game/GameView.vue +++ b/src/routes/game/GameView.vue @@ -230,7 +230,7 @@ :scuttled-by="card.scuttledBy" @click="targetOpponentPointCard(index)" /> - + - + { // Set only while a nine's topdeck animation is in flight. Drives both the face-down flip // (GameCard renders its back when suit/rank are withheld) and the toward-the-deck exit. const topdeckedCard = ref(null); + // Which list the topdecked card sat in, captured before the server state lands so the row + // it leaves from animates toward the deck while the others keep their usual direction: + // 'playerPoints' | 'playerFaceCards' | 'playerJacks' | 'opponent...' equivalents. + const topdeckedCardZone = ref(null); const lastEventPlayedBy = ref(null); // GameOver const gameIsOver = ref(false); @@ -312,6 +316,7 @@ export const useGameStore = defineStore('game', () => { lastEventChange.value = null; lastEventOneOffRank.value = null; topdeckedCard.value = null; + topdeckedCardZone.value = null; lastEventPlayedBy.value = null; lastEventThreeTarget.value = null; gameIsOver.value = false; @@ -376,14 +381,36 @@ export const useGameStore = defineStore('game', () => { updateGame(game); } /** - * Animates a nine putting its target on top of the deck, in three stages so that each - * render has only one kind of change in it. + * Finds which list a card currently sits in, so the row it leaves from can animate toward + * the deck while every other row keeps its usual direction. + */ + function findFieldZone(cardId) { + const sides = [ [ 'player', player.value ], [ 'opponent', opponent.value ] ]; + for (const [ side, fieldPlayer ] of sides) { + if (!fieldPlayer) { + continue; + } + if (fieldPlayer.faceCards?.some(({ id }) => id === cardId)) { + return `${side}FaceCards`; + } + if (fieldPlayer.points?.some(({ id }) => id === cardId)) { + return `${side}Points`; + } + if (fieldPlayer.points?.some(({ attachments }) => attachments?.some(({ id }) => id === cardId))) { + return `${side}Jacks`; + } + } + return null; + } + + /** + * Animates a nine putting its target on top of the deck: the card flips face down where it + * sits, then leaves for the deck as the server state lands. * - * It has to be staged: a nine can both remove a card from the opponent's points (which - * should travel toward the deck) and hand a jacked point card back to its owner (which - * should travel toward that owner). A TransitionGroup has a single name per render, so - * those two leave directions cannot coexist. Separating them into different renders lets - * each use the right one, and avoids having to persist the target's type through resolve. + * Only one card is ever targeted, so the exit and any knock-on move can share a render. A + * topdecked jack leaves through the jacks list toward the deck while the point card it was + * stealing goes back to its owner through the points list -- separate TransitionGroups, so + * the two directions never compete. */ async function processNines(targetCard, game) { if (!targetCard || !player.value) { @@ -392,54 +419,25 @@ export const useGameStore = defineStore('game', () => { } // Drop out of the countering phase up front, the way processThrees and processFives do. - // The authoritative state does not land until stage 3, so without this the "waiting for + // The authoritative state does not land until stage 2, so without this the "waiting for // opponent to counter" scrim covers the whole animation. phase.value = GamePhase.MAIN; // Stage 1: the target flips face down where it sits. Matches CARD_FLIP's --duration-slow // so the flip finishes before the card starts moving. + topdeckedCardZone.value = findFieldZone(targetCard.id); topdeckedCard.value = targetCard; await sleep(1000); - // Stage 2: it leaves the board toward the deck, and the deck grows to match - removeCardFromField(targetCard.id); - deck.value.unshift({ isHidden: true }); + // Stage 2: the server state lands. The target exits toward the deck, and a topdecked + // jack's point card heads back to its owner in the same render. + updateGame(game); await sleep(1000); - // Stage 3: authoritative state. A topdecked jack returns its point card here, which is - // why this is a separate render from the exit above. topdeckedCard.value = null; - updateGame(game); + topdeckedCardZone.value = null; } - /** - * Removes a card from either player's points or face cards, including jacks attached to a - * point card. Used to play a card's exit animation before the server state is applied. - */ - function removeCardFromField(cardId) { - players.value.forEach((fieldPlayer) => { - if (!fieldPlayer) { - return; - } - const faceCardIndex = fieldPlayer.faceCards?.findIndex(({ id }) => id === cardId) ?? -1; - if (faceCardIndex > -1) { - fieldPlayer.faceCards.splice(faceCardIndex, 1); - } - - const pointIndex = fieldPlayer.points?.findIndex(({ id }) => id === cardId) ?? -1; - if (pointIndex > -1) { - fieldPlayer.points.splice(pointIndex, 1); - return; - } - - fieldPlayer.points?.forEach((pointCard) => { - const jackIndex = pointCard.attachments?.findIndex(({ id }) => id === cardId) ?? -1; - if (jackIndex > -1) { - pointCard.attachments.splice(jackIndex, 1); - } - }); - }); - } async function processThrees(chosenCard, game) { phase.value = GamePhase.MAIN; const three = game.resolved; @@ -804,6 +802,7 @@ export const useGameStore = defineStore('game', () => { lastEventChange, lastEventOneOffRank, topdeckedCard, + topdeckedCardZone, lastEventPlayedBy, gameIsOver, winnerPNum, From 420d7f2dcec9e4fbce9d921762490a30496fb86c Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Fri, 18 Sep 2026 19:09:03 -0400 Subject: [PATCH 11/12] Revert "refactor(nines): collapse the topdeck animation to two stages" Restores the three-stage timing: the card gets its own beat to reach the deck before a topdecked jack's point card comes home. Two stages worked and was less code, but three reads better, which is the point of the animation. Reverts only the timing. Kept from e52a68b9: - per-row direction from the zone the target sat in, rather than pointing every row at the deck whenever a topdeck is running - the jacks lists on their own transitions - switch on the one-off rank in inGameEvents - topdeckedCardId inlined into its only caller - the to-deck directions generated from one @each removeCardFromField comes back, since stage two needs something to animate before the server state lands. The comment above processNines no longer claims the staging is forced. It is a deliberate choice about pacing: the two leave directions could share a render, because a jack and its point card leave through separate TransitionGroups. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- src/stores/game.js | 51 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/src/stores/game.js b/src/stores/game.js index 0e7114106..682629055 100644 --- a/src/stores/game.js +++ b/src/stores/game.js @@ -404,13 +404,13 @@ export const useGameStore = defineStore('game', () => { } /** - * Animates a nine putting its target on top of the deck: the card flips face down where it - * sits, then leaves for the deck as the server state lands. + * Animates a nine putting its target on top of the deck, in three stages. * - * Only one card is ever targeted, so the exit and any knock-on move can share a render. A - * topdecked jack leaves through the jacks list toward the deck while the point card it was - * stealing goes back to its owner through the points list -- separate TransitionGroups, so - * the two directions never compete. + * The exit and the knock-on move could share a render -- a topdecked jack leaves through + * the jacks list while its point card goes home through the points list, and those are + * separate TransitionGroups, so the directions do not compete. They are kept apart on + * purpose: giving the card its own beat to reach the deck before the point card comes + * home reads better than both moving at once. */ async function processNines(targetCard, game) { if (!targetCard || !player.value) { @@ -419,7 +419,7 @@ export const useGameStore = defineStore('game', () => { } // Drop out of the countering phase up front, the way processThrees and processFives do. - // The authoritative state does not land until stage 2, so without this the "waiting for + // The authoritative state does not land until stage 3, so without this the "waiting for // opponent to counter" scrim covers the whole animation. phase.value = GamePhase.MAIN; @@ -429,13 +429,44 @@ export const useGameStore = defineStore('game', () => { topdeckedCard.value = targetCard; await sleep(1000); - // Stage 2: the server state lands. The target exits toward the deck, and a topdecked - // jack's point card heads back to its owner in the same render. - updateGame(game); + // Stage 2: the card leaves for the deck on its own, and the deck grows to match + removeCardFromField(targetCard.id); + deck.value.unshift({ isHidden: true }); await sleep(1000); + // Stage 3: the server state lands, bringing a topdecked jack's point card home topdeckedCard.value = null; topdeckedCardZone.value = null; + updateGame(game); + } + + /** + * Removes a card from either player's points or face cards, including jacks attached to a + * point card. Lets a card play its exit before the server state is applied. + */ + function removeCardFromField(cardId) { + players.value.forEach((fieldPlayer) => { + if (!fieldPlayer) { + return; + } + const faceCardIndex = fieldPlayer.faceCards?.findIndex(({ id }) => id === cardId) ?? -1; + if (faceCardIndex > -1) { + fieldPlayer.faceCards.splice(faceCardIndex, 1); + } + + const pointIndex = fieldPlayer.points?.findIndex(({ id }) => id === cardId) ?? -1; + if (pointIndex > -1) { + fieldPlayer.points.splice(pointIndex, 1); + return; + } + + fieldPlayer.points?.forEach((pointCard) => { + const jackIndex = pointCard.attachments?.findIndex(({ id }) => id === cardId) ?? -1; + if (jackIndex > -1) { + pointCard.attachments.splice(jackIndex, 1); + } + }); + }); } async function processThrees(chosenCard, game) { From d1b373e1e6fb6abac596caaf683e103f6da4a183 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Sat, 19 Sep 2026 08:23:42 -0400 Subject: [PATCH 12/12] refactor(nines): make toDeckTransition a method, tidy helper order Review fixes: - toDeckTransition was a computed returning an arrow function, so callers read the computed and then invoked its result. That works, but only as a workaround for having put an argument-taking helper in the computed block. It is a plain method now, taking side and area directly. The transition computeds still track $vuetify.display.xs, since the method reads it while they evaluate. - Move removeCardFromField above processNines, next to findFieldZone, so both helpers precede their caller. - Reword a nines spec comment to state what happened rather than what did not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- src/routes/game/GameView.vue | 26 ++++----- src/stores/game.js | 58 +++++++++---------- .../specs/in-game/one-offs/9_nines.spec.js | 2 +- 3 files changed, 42 insertions(+), 44 deletions(-) diff --git a/src/routes/game/GameView.vue b/src/routes/game/GameView.vue index 4ab38e529..f9d4ae598 100644 --- a/src/routes/game/GameView.vue +++ b/src/routes/game/GameView.vue @@ -542,20 +542,6 @@ export default { /////////////////////////// // Transition Directions // /////////////////////////// - /** - * Which way "toward the deck" is, for a card leaving the field during a nine's topdeck. - * At xs the deck sits below the field; above xs it sits to the upper left, so a card on - * the player's half travels up and left while one on the opponent's half goes straight - * across. Jacks follow their own point row. - */ - toDeckTransition() { - return (side, area) => { - if (this.$vuetify.display.xs) { - return area === 'points' ? Transitions.TO_DECK_DOWN : Transitions.TO_DECK_DOWN_LEFT; - } - return side === 'player' ? Transitions.TO_DECK_UP_LEFT : Transitions.TO_DECK_LEFT; - }; - }, /** * True for the one list a topdecked card is leaving from. Every other list keeps its * usual direction, so a topdecked jack can head for the deck while the point card it was @@ -786,6 +772,18 @@ export default { this.snackbarStore.alert(this.t(messageKey)); this.clearSelection(); }, + /** + * Which way "toward the deck" is, for a card leaving the field during a nine's topdeck. + * At xs the deck sits below the field; above xs it sits to the upper left, so a card on + * the player's half travels up and left while one on the opponent's half goes straight + * across. Jacks follow their own point row. + */ + toDeckTransition(side, area) { + if (this.$vuetify.display.xs) { + return area === 'points' ? Transitions.TO_DECK_DOWN : Transitions.TO_DECK_DOWN_LEFT; + } + return side === 'player' ? Transitions.TO_DECK_UP_LEFT : Transitions.TO_DECK_LEFT; + }, isTopdeckedCard(card) { return card.id === (this.gameStore.topdeckedCard?.id ?? null); }, diff --git a/src/stores/game.js b/src/stores/game.js index 682629055..06c3b820a 100644 --- a/src/stores/game.js +++ b/src/stores/game.js @@ -403,6 +403,35 @@ export const useGameStore = defineStore('game', () => { return null; } + /** + * Removes a card from either player's points or face cards, including jacks attached to a + * point card. Lets a card play its exit before the server state is applied. + */ + function removeCardFromField(cardId) { + players.value.forEach((fieldPlayer) => { + if (!fieldPlayer) { + return; + } + const faceCardIndex = fieldPlayer.faceCards?.findIndex(({ id }) => id === cardId) ?? -1; + if (faceCardIndex > -1) { + fieldPlayer.faceCards.splice(faceCardIndex, 1); + } + + const pointIndex = fieldPlayer.points?.findIndex(({ id }) => id === cardId) ?? -1; + if (pointIndex > -1) { + fieldPlayer.points.splice(pointIndex, 1); + return; + } + + fieldPlayer.points?.forEach((pointCard) => { + const jackIndex = pointCard.attachments?.findIndex(({ id }) => id === cardId) ?? -1; + if (jackIndex > -1) { + pointCard.attachments.splice(jackIndex, 1); + } + }); + }); + } + /** * Animates a nine putting its target on top of the deck, in three stages. * @@ -440,35 +469,6 @@ export const useGameStore = defineStore('game', () => { updateGame(game); } - /** - * Removes a card from either player's points or face cards, including jacks attached to a - * point card. Lets a card play its exit before the server state is applied. - */ - function removeCardFromField(cardId) { - players.value.forEach((fieldPlayer) => { - if (!fieldPlayer) { - return; - } - const faceCardIndex = fieldPlayer.faceCards?.findIndex(({ id }) => id === cardId) ?? -1; - if (faceCardIndex > -1) { - fieldPlayer.faceCards.splice(faceCardIndex, 1); - } - - const pointIndex = fieldPlayer.points?.findIndex(({ id }) => id === cardId) ?? -1; - if (pointIndex > -1) { - fieldPlayer.points.splice(pointIndex, 1); - return; - } - - fieldPlayer.points?.forEach((pointCard) => { - const jackIndex = pointCard.attachments?.findIndex(({ id }) => id === cardId) ?? -1; - if (jackIndex > -1) { - pointCard.attachments.splice(jackIndex, 1); - } - }); - }); - } - async function processThrees(chosenCard, game) { phase.value = GamePhase.MAIN; const three = game.resolved; 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 3c1f6cae1..5f124836a 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 @@ -655,7 +655,7 @@ describe('Playing NINES', () => { .get('[data-cy=cannot-counter-resolve]') .click(); - // The seven did not come back to the player's hand + // The seven is back on top of the deck assertGameState(1, { p0Hand: [], p0Points: [ Card.THREE_OF_CLUBS ],