From 4b307f2880c2a81fb07e8eb0ceddd3a99dff7706 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Wed, 9 Sep 2026 09:22:12 -0400 Subject: [PATCH 1/4] feat: nines return two cards to hand, unfrozen Nines are one of the highest ranks in the deck but have the most niche, least impactful one-off. This buffs the effect: a nine one-off now returns TWO cards from the opponent's board to their controller's hand, and neither returned card is frozen, so both are immediately replayable. Ships as a beta for the Spades season, announced on the home page, with a community poll toward the end of 2026 deciding whether it stays. Rules: - A nine one-off requires exactly two distinct targets, any mix of point cards, royals/glasses, and top jacks. - Both targets resolve SIMULTANEOUSLY: each card goes to the hand of whoever controlled it before the nine resolved. So targeting a jack and the point card it is stealing sends both to the jack holder's hand. - Only the top jack of a stack is targetable. - Any queen now blocks a nine outright: a queen protects everything else, leaving itself the only legal target, so two legal targets are impossible. Enforced in the validator and pre-emptively in the move-choice UI. Twos keep their existing queen rules. Notable details: - isFrozen was never persisted; it was re-derived on unpack from "resolved is a 9 and this hand card equals targetCard". That derivation is removed, so nothing freezes anymore. The freeze plumbing is left dormant rather than deleted so a poll-driven revert stays a clean git revert. - Second-target state is carried in additive nullable columns (oneOffTargetTwo, oneOffTargetTwoType, targetCardTwo) rather than widening the existing singular fields, which twos and the seven flow also read. - Nine validation is extracted to a shared helper called from both the one-off and seven-one-off validators, which previously duplicated it. - The AI move generator now enumerates two-target pairs, and its jack bodies no longer omit targetType. - Fixes a pre-existing leak: the fizzle path never cleared the one-off target fields. It was masked because the next one-off overwrote them. Selected targets get a distinct treatment (pink border/tint plus a check badge) and the green "valid target" overlay is suppressed once a card is chosen, so selection does not read as disabled. Requires a manual migration before deploy, as the repo has no migration tooling and staging/production use migrate: 'safe': ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "oneOffTargetTwo" text; ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "oneOffTargetTwoType" text; ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "targetCardTwo" text; Rules version bumped to 3.0.0, since 2.0.0 is taken by the topdeck-nines rework (#1379) that landed on main first. This change replaces that effect: the topdeck animation and its to-deck transitions are removed, and rank 9 now animates toward the controlling player's hand again. The shared is-top-jack helper introduced by #1379 is kept and reused, so the top-jack rule lives in one place for both twos and nines. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- .../game/load-fixture-gamestate.js | 2 + .../ai/get-move-bodies-for-move-type.js | 142 +-- .../game-states/create-socket-events.js | 4 + api/helpers/game-states/deal-cards.js | 2 + api/helpers/game-states/get-log.js | 13 +- .../game-states/moves/one-off/execute.js | 9 + .../game-states/moves/one-off/validate.js | 18 +- .../game-states/moves/resolve/execute.js | 7 +- api/helpers/game-states/moves/resolve/nine.js | 87 +- .../moves/seven-one-off/execute.js | 9 + .../moves/seven-one-off/validate.js | 20 +- api/helpers/game-states/pack-gamestate.js | 2 + api/helpers/game-states/unpack-gamestate.js | 5 + api/helpers/game-states/validate-gamestate.js | 3 + .../game-states/validate-nine-targets.js | 73 ++ api/models/GameStateRow.js | 16 + docs/game-rules.md | 2 +- docs/game-state-api.md | 15 +- src/plugins/sockets/inGameEvents.js | 13 +- src/routes/game/GameView.vue | 207 ++-- src/routes/game/components/GameCard.vue | 43 + .../game/components/MoveChoiceOverlay.vue | 42 +- .../components/TargetSelectionOverlay.vue | 111 ++- .../game/components/dialogs/GameDialogs.vue | 2 + .../components/CannotCounterDialog.vue | 9 + .../ChooseWhetherToCounterDialog.vue | 9 + .../data/announcementData.js | 14 +- src/sass/transitions.scss | 34 - src/stores/game.js | 132 +-- src/translations/de.json | 33 +- src/translations/en.json | 31 +- src/translations/es.json | 31 +- src/translations/fr.json | 33 +- src/translations/ukr.json | 31 +- tests/e2e/fixtures/snackbarError.js | 8 + tests/e2e/specs/in-game/handLimit.spec.js | 163 +++- .../one-offs/7_sevens/opponent_sevens.spec.js | 75 +- .../one-offs/7_sevens/player_sevens.spec.js | 27 +- .../specs/in-game/one-offs/9_nines.spec.js | 900 ++++++++---------- .../one-offs/targeting_cleanup.spec.js | 66 +- tests/e2e/support/commands.js | 60 +- tests/unit/fixtures/gameStates/points.js | 9 + tests/unit/fixtures/gameStates/resolveNine.js | 21 +- .../unit/fixtures/gameStates/resolveThree.js | 9 + utils/Transitions.json | 6 +- utils/rulesVersion.js | 3 +- 46 files changed, 1491 insertions(+), 1060 deletions(-) create mode 100644 api/helpers/game-states/validate-nine-targets.js diff --git a/api/controllers/game/load-fixture-gamestate.js b/api/controllers/game/load-fixture-gamestate.js index 0c757005c..6da950201 100644 --- a/api/controllers/game/load-fixture-gamestate.js +++ b/api/controllers/game/load-fixture-gamestate.js @@ -60,8 +60,10 @@ module.exports = async function (req, res) { discardedCards: [], oneOff: null, oneOffTarget: null, + oneOffTargetTwo: null, resolved: null, playedCard: null, + targetCardTwo: null, gameId: game.id, playedBy: 0, moveType: MoveType.LOADFIXTURE, 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 04b572dc4..656155853 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 @@ -1,6 +1,70 @@ const MoveType = require('../../../../utils/MoveType'); const TargetType = require('../../../../utils/TargetType'); +/** + * Legal targets for a two: royals, glasses eights, and the top jack of each point card. + * Point cards themselves are never legal targets for a two. + */ +function getTwoTargets(opponentPoints, opponentFaceCards) { + const faceCardTargets = opponentFaceCards.map(({ id }) => ({ + targetId: id, + targetType: TargetType.faceCard, + })); + const jackTargets = opponentPoints + .filter(({ attachments }) => attachments.length) + .map(({ attachments }) => ({ targetId: attachments.at(-1).id, targetType: TargetType.jack })); + + return [ ...faceCardTargets, ...jackTargets ]; +} + +/** + * Every legal pair of targets for a nine. Nines return two cards, so they need two distinct + * targets, and any queen blocks them outright (a queen leaves itself the only legal target). + * Only the top jack of each point card is targetable. + */ +function getNineTargetPairs(opponentPoints, opponentFaceCards) { + if (opponentFaceCards.some(({ rank }) => rank === 12)) { + return []; + } + + const targets = [ + ...opponentPoints.map(({ id }) => ({ targetId: id, targetType: TargetType.point })), + ...getTwoTargets(opponentPoints, opponentFaceCards), + ]; + + const pairs = []; + for (let i = 0; i < targets.length; i++) { + for (let j = i + 1; j < targets.length; j++) { + pairs.push([ targets[i], targets[j] ]); + } + } + + return pairs; +} + +/** Builds the move bodies for playing one targeted one-off card, given its rank's targeting rules */ +function getTargetedOneOffBodies({ moveType, playedBy, card, opponentPoints, opponentFaceCards }) { + if (card.rank === 9) { + return getNineTargetPairs(opponentPoints, opponentFaceCards).map(([ targetOne, targetTwo ]) => ({ + moveType, + playedBy, + cardId: card.id, + targetId: targetOne.targetId, + targetType: targetOne.targetType, + targetIdTwo: targetTwo.targetId, + targetTypeTwo: targetTwo.targetType, + })); + } + + return getTwoTargets(opponentPoints, opponentFaceCards).map(({ targetId, targetType }) => ({ + moveType, + playedBy, + cardId: card.id, + targetId, + targetType, + })); +} + module.exports = { friendlyName: 'Get move bodies for move type', @@ -84,38 +148,13 @@ 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: 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, - targetType: TargetType.jack, - }); - } - } + res.push(...getTargetedOneOffBodies({ + moveType, + playedBy, + card: twoOrNine, + opponentPoints, + opponentFaceCards, + })); } break; } @@ -236,38 +275,13 @@ module.exports = { } for (let targetedOneOff of targetedOneOffs) { - for (let opponentFaceCard of opponentFaceCards) { - res.push({ - moveType, - playedBy, - cardId: targetedOneOff.id, - targetId: opponentFaceCard.id, - targetType: TargetType.faceCard, - }); - } - - 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, - playedBy, - cardId: targetedOneOff.id, - targetId: opponentPoint.attachments.at(-1).id, - targetType: TargetType.jack - }); - } - } + res.push(...getTargetedOneOffBodies({ + moveType, + playedBy, + card: targetedOneOff, + opponentPoints, + opponentFaceCards, + })); } break; diff --git a/api/helpers/game-states/create-socket-events.js b/api/helpers/game-states/create-socket-events.js index e197401ea..d3adda5b8 100644 --- a/api/helpers/game-states/create-socket-events.js +++ b/api/helpers/game-states/create-socket-events.js @@ -69,6 +69,7 @@ module.exports = { 'playedBy', 'playedCard', 'targetCard', + 'targetCardTwo', 'discardedCards', 'resolved', ]); @@ -148,6 +149,8 @@ module.exports = { oneOff: gameState.oneOff, oneOffTarget: gameState.oneOffTarget, oneOffTargetType: gameState.oneOffTargetType, + oneOffTargetTwo: gameState.oneOffTargetTwo, + oneOffTargetTwoType: gameState.oneOffTargetTwoType, lastEvent: { change: gameState.moveType, pNum, @@ -172,6 +175,7 @@ module.exports = { // Conditionally included properties if truthy ...(gameState.playedCard && { playedCard: gameState.playedCard }), ...(gameState.targetCard && { targetCard: gameState.targetCard }), + ...(gameState.targetCardTwo && { targetCardTwo: gameState.targetCardTwo }), ...(gameState.resolved && { oneOff: gameState.resolved }), ...(chosenCard && { chosenCard }), ...(discardedCards && { discardedCards }), diff --git a/api/helpers/game-states/deal-cards.js b/api/helpers/game-states/deal-cards.js index 4696adceb..0f8f206f2 100644 --- a/api/helpers/game-states/deal-cards.js +++ b/api/helpers/game-states/deal-cards.js @@ -59,9 +59,11 @@ module.exports = { playedBy: 1, // p1 "deals" playedCard: null, targetCard: null, + targetCardTwo: null, discardedCards: [], oneOff: null, oneOffTarget: null, + oneOffTargetTwo: null, resolving: null, }; diff --git a/api/helpers/game-states/get-log.js b/api/helpers/game-states/get-log.js index 5bd5134a9..77d404287 100644 --- a/api/helpers/game-states/get-log.js +++ b/api/helpers/game-states/get-log.js @@ -18,7 +18,7 @@ module.exports = { fn: function ({ game }, exits) { const getMessage = (row, i) => { - const { moveType, playedCard, targetCard, resolved, deck, discardedCards } = row; + const { moveType, playedCard, targetCard, targetCardTwo, resolved, deck, discardedCards } = row; const { convertStrToCard } = sails.helpers.gameStates; const getFullCardName = (card) => { @@ -31,6 +31,7 @@ module.exports = { const playedCardName = playedCard ? getFullCardName(playedCard) : null; const targetCardName = targetCard ? getFullCardName(targetCard) : null; + const targetCardTwoName = targetCardTwo ? getFullCardName(targetCardTwo) : null; const resolvedCardName = resolved ? getFullCardName(resolved) : null; const getResolveFiveMessage = () => { @@ -67,7 +68,9 @@ module.exports = { let log = `${player} played the ${playedCardName} as a one-off to ${gameText.moves.effects[playedCardObj.rank]}`; if (targetCardName) { - log += `, targeting the ${targetCardName}.`; + log += `, targeting the ${targetCardName}${ + targetCardTwoName ? ` and the ${targetCardTwoName}` : '' + }.`; } else { log += '.'; } @@ -108,7 +111,9 @@ module.exports = { )} and ${getFullCardName(deck[1])}.`; case 9: - return `The ${resolvedCardName} one-off resolves, putting the ${targetCardName} on top of the deck.`; + return `The ${resolvedCardName} one-off resolves, returning the ${targetCardName}${ + targetCardTwoName ? ` and the ${targetCardTwoName}` : '' + } to ${player}'s hand.`; } break; @@ -170,7 +175,7 @@ module.exports = { case MoveType.SEVEN_TARGETED_ONE_OFF: return `${player} played the ${playedCardName} from the top of the deck as a one-off to ${ gameText.moves.effects[playedCard.rank] - }, targeting the ${targetCardName}.`; + }, targeting the ${targetCardName}${targetCardTwoName ? ` and the ${targetCardTwoName}` : ''}.`; case MoveType.PASS: return `${player} passed.`; diff --git a/api/helpers/game-states/moves/one-off/execute.js b/api/helpers/game-states/moves/one-off/execute.js index 277f04762..5a47ad79f 100644 --- a/api/helpers/game-states/moves/one-off/execute.js +++ b/api/helpers/game-states/moves/one-off/execute.js @@ -51,6 +51,13 @@ module.exports = { opponent, ) : null; + // Nines return two cards, so they carry a second target through countering + const targetCardTwo = requestedMove.targetIdTwo ? sails.helpers.gameStates.findTargetCard( + requestedMove.targetIdTwo, + requestedMove.targetTypeTwo, + opponent, + ) : null; + result = { ...result, ...requestedMove, @@ -63,6 +70,8 @@ module.exports = { resolved: null, oneOffTarget: targetCard, oneOffTargetType: requestedMove.targetType ?? null, + oneOffTargetTwo: targetCardTwo, + oneOffTargetTwoType: requestedMove.targetTypeTwo ?? null, }; return exits.success(result); diff --git a/api/helpers/game-states/moves/one-off/validate.js b/api/helpers/game-states/moves/one-off/validate.js index e1cb41517..b1f958923 100644 --- a/api/helpers/game-states/moves/one-off/validate.js +++ b/api/helpers/game-states/moves/one-off/validate.js @@ -18,6 +18,8 @@ module.exports = { * @param { String } requestedMove.cardId - Card Played for points * @param { String } [ requestedMove.targetId ] - OPTIONAL target of one-off used for 2's and 9's * @param { 'point' | 'faceCard' | 'jack' } [ requestedMove.targetType ] - OPTIONAL where one-off target is located + * @param { String } [ requestedMove.targetIdTwo ] - OPTIONAL second target, required for 9's + * @param { 'point' | 'faceCard' | 'jack' } [ requestedMove.targetTypeTwo ] - OPTIONAL second target's location */ requestedMove: { type: 'ref', @@ -69,9 +71,8 @@ module.exports = { case 6: return exits.success(); - // 2 and 9 require legal target - case 2: - case 9: { + // Two requires one legal target + case 2: { const targetCard = sails.helpers.gameStates.findTargetCard( requestedMove.targetId, requestedMove.targetType, @@ -82,7 +83,7 @@ module.exports = { throw new BadRequestError(`Can't find the ${requestedMove.targetId} on opponent's board`); } - if (playedCard.rank === 2 && ![ 'faceCard', 'jack' ].includes(requestedMove.targetType)) { + if (![ 'faceCard', 'jack' ].includes(requestedMove.targetType)) { throw new BadRequestError('Twos can only target royals or glasses'); } @@ -118,6 +119,15 @@ module.exports = { } } + // Nine requires two legal targets, and is blocked by any queen + case 9: { + const nineError = sails.helpers.gameStates.validateNineTargets(requestedMove, opponent); + if (nineError) { + throw new BadRequestError(nineError); + } + return exits.success(); + } + // Three requires non-three card(s) in scrap case 3: if (currentState.scrap.every((card) => card.rank === 3)) { diff --git a/api/helpers/game-states/moves/resolve/execute.js b/api/helpers/game-states/moves/resolve/execute.js index 728b0eedb..7670802ad 100644 --- a/api/helpers/game-states/moves/resolve/execute.js +++ b/api/helpers/game-states/moves/resolve/execute.js @@ -56,7 +56,7 @@ module.exports = { const activePlayer = result.turn % 2 === 0 ? result.p0 : result.p1; const playerMustDiscard = activePlayer.hand.length > 8; - // If one-off fizzles, make no other changes + // If one-off fizzles, make no other changes to the board if (fizzles) { result.moveType = MoveType.FIZZLE; result.scrap.push(result.oneOff); @@ -64,6 +64,8 @@ module.exports = { // Nothing is pending anymore, so the target slots have to clear too result.oneOffTarget = null; result.oneOffTargetType = null; + result.oneOffTargetTwo = null; + result.oneOffTargetTwoType = null; result.phase = playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN; result.turn = playerMustDiscard ? result.turn : result.turn + 1; return exits.success(result); @@ -102,8 +104,11 @@ module.exports = { ...result, oneOff: null, targetCard: result.oneOffTarget, + targetCardTwo: result.oneOffTargetTwo ?? null, oneOffTarget: null, oneOffTargetType: null, + oneOffTargetTwo: null, + oneOffTargetTwoType: null, phase: playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN, turn: playerMustDiscard ? result.turn : result.turn + 1, }; diff --git a/api/helpers/game-states/moves/resolve/nine.js b/api/helpers/game-states/moves/resolve/nine.js index f0296e12f..e47e494e1 100644 --- a/api/helpers/game-states/moves/resolve/nine.js +++ b/api/helpers/game-states/moves/resolve/nine.js @@ -3,12 +3,15 @@ const TargetType = require('../../../../../utils/TargetType.json'); module.exports = { friendlyName: 'Resolve Nine One-Off', - description: 'Returns new GameState resulting from resolving a nine, which puts the target card on top of the deck', + description: + 'Returns new GameState resulting from resolving a nine, which returns two target cards to the hand of ' + + 'whoever controlled them. Both targets resolve simultaneously: each card goes to its controller as of ' + + 'the board state before the nine resolved, and neither is frozen.', inputs: { currentState: { type: 'ref', - description: 'The latest GameState before the nine puts the target card on top of the deck', + description: 'The latest GameState before the nine returns its two target cards to hand', required: true, }, playedBy: { @@ -20,50 +23,62 @@ module.exports = { fn: ({ currentState, playedBy }, exits) => { const result = _.cloneDeep(currentState); - // 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; + // The player resolving the one-off is the opposite of the one who played it, so `playedBy` + // identifies the player whose board holds both targets -- not the nine's caster. + const victim = playedBy ? result.p1 : result.p0; + const caster = 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: [] }); + const targets = [ + { card: result.oneOffTarget, targetType: result.oneOffTargetType }, + { card: result.oneOffTargetTwo, targetType: result.oneOffTargetTwoType }, + ].filter(({ card, targetType }) => card && targetType); - 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); + // Point cards that are themselves targets must not be handed back to the caster when a jack + // on top of them is also targeted -- they belong to whoever controlled them, i.e. the victim. + const targetedPointIds = targets + .filter(({ targetType }) => targetType === TargetType.point) + .map(({ card }) => card.id); - // scrap all attachments - result.scrap.push(...targetCard.attachments); + const returnToVictim = (card) => victim.hand.push({ ...card, isFrozen: false, attachments: [] }); - topDeck(targetCard); + // Jacks first: removing one hands its point card back before we look for point targets + for (const { card } of targets.filter(({ targetType }) => targetType === TargetType.jack)) { + const hostIndex = victim.points.findIndex(({ attachments }) => + attachments.some(({ id }) => id === card.id)); - return exits.success(result); - } + // Sanity check -- targets can't disappear mid-resolution, so just skip if one is missing + if (hostIndex === -1) { + continue; + } + + const host = victim.points[hostIndex]; + const jackIndex = host.attachments.findIndex(({ id }) => id === card.id); + const [ jack ] = host.attachments.splice(jackIndex, 1); + returnToVictim(jack); - // 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); + // Only the top jack can be targeted, so removing it always reverts control to the caster. + // When the point card is a target too it stays put and goes to hand with the jack instead. + if (!targetedPointIds.includes(host.id)) { + victim.points.splice(hostIndex, 1); + caster.points.push(host); + } } - const [ targetCard ] = opponent.points.splice(targetIndex, 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(); + for (const { card, targetType } of targets.filter(({ targetType }) => targetType !== TargetType.jack)) { + const collection = victim[`${targetType}s`]; + const targetIndex = collection.findIndex(({ id }) => id === card.id); - // Card the jack was stealing goes back to other player - player.points.push( - { - ...targetCard, - }); + if (targetIndex === -1) { + continue; + } + + const [ targetCard ] = collection.splice(targetIndex, 1); + returnToVictim(targetCard); + + // Any jack still riding this card wasn't targeted, so it's scrapped + result.scrap.push(...targetCard.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/execute.js b/api/helpers/game-states/moves/seven-one-off/execute.js index 3d9b4b528..a59750005 100644 --- a/api/helpers/game-states/moves/seven-one-off/execute.js +++ b/api/helpers/game-states/moves/seven-one-off/execute.js @@ -51,6 +51,13 @@ module.exports = { opponent, ) : null; + // Nines return two cards, so they carry a second target through countering + const targetCardTwo = requestedMove.targetIdTwo ? sails.helpers.gameStates.findTargetCard( + requestedMove.targetIdTwo, + requestedMove.targetTypeTwo, + opponent, + ) : null; + const { oneOff } = result; result.scrap.push(oneOff); @@ -66,6 +73,8 @@ module.exports = { resolved: null, oneOffTarget: targetCard, oneOffTargetType: requestedMove.targetType ?? null, + oneOffTargetTwo: targetCardTwo, + oneOffTargetTwoType: requestedMove.targetTypeTwo ?? null, }; 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 219b3006e..e1f563579 100644 --- a/api/helpers/game-states/moves/seven-one-off/validate.js +++ b/api/helpers/game-states/moves/seven-one-off/validate.js @@ -17,6 +17,10 @@ module.exports = { * @param { Object } requestedMove - Object describing the request to play one-off via seven * @param { String } requestedMove.cardId - Card Played as one-off * @param { MoveType.SEVEN_ONE_OFF } requestedMove.moveType - Specifies that this a sevenOneOff move + * @param { String } [ requestedMove.targetId ] - OPTIONAL target of one-off used for 2's and 9's + * @param { 'point' | 'faceCard' | 'jack' } [ requestedMove.targetType ] - OPTIONAL where one-off target is located + * @param { String } [ requestedMove.targetIdTwo ] - OPTIONAL second target, required for 9's + * @param { 'point' | 'faceCard' | 'jack' } [ requestedMove.targetTypeTwo ] - OPTIONAL second target's location */ requestedMove: { type: 'ref', @@ -59,9 +63,8 @@ module.exports = { case 6: return exits.success(); - // 2 and 9 require legal target - case 2: - case 9: { + // Two requires one legal target + case 2: { const targetCard = sails.helpers.gameStates.findTargetCard( requestedMove.targetId, requestedMove.targetType, @@ -72,7 +75,7 @@ module.exports = { throw new BadRequestError(`Can't find the ${requestedMove.targetId} on opponent's board`); } - if (playedCard.rank === 2 && ![ 'faceCard', 'jack' ].includes(requestedMove.targetType)) { + if (![ 'faceCard', 'jack' ].includes(requestedMove.targetType)) { throw new BadRequestError('Twos can only target royals or glasses'); } @@ -106,6 +109,15 @@ module.exports = { } } + // Nine requires two legal targets, and is blocked by any queen + case 9: { + const nineError = sails.helpers.gameStates.validateNineTargets(requestedMove, opponent); + if (nineError) { + throw new BadRequestError(nineError); + } + return exits.success(); + } + // Three requires non-three card(s) in scrap case 3: if (currentState.scrap.every((card) => card.rank === 3)) { diff --git a/api/helpers/game-states/pack-gamestate.js b/api/helpers/game-states/pack-gamestate.js index af4018abb..d28728ffa 100644 --- a/api/helpers/game-states/pack-gamestate.js +++ b/api/helpers/game-states/pack-gamestate.js @@ -22,8 +22,10 @@ module.exports = { 'scrap', 'playedCard', 'targetCard', + 'targetCardTwo', 'oneOff', 'oneOffTarget', + 'oneOffTargetTwo', 'twos', 'resolved', 'discardedCards', diff --git a/api/helpers/game-states/unpack-gamestate.js b/api/helpers/game-states/unpack-gamestate.js index f92d5ea21..695cfc4c0 100644 --- a/api/helpers/game-states/unpack-gamestate.js +++ b/api/helpers/game-states/unpack-gamestate.js @@ -20,8 +20,10 @@ module.exports = { 'scrap', 'playedCard', 'targetCard', + 'targetCardTwo', 'oneOff', 'oneOffTarget', + 'oneOffTargetTwo', 'twos', 'resolved', 'discardedCards', @@ -71,9 +73,12 @@ module.exports = { twos: convertedData.twos, playedCard: convertedData.playedCard ?? null, targetCard: convertedData.targetCard ?? null, + targetCardTwo: convertedData.targetCardTwo ?? null, oneOff: convertedData.oneOff ?? null, oneOffTarget: convertedData.oneOffTarget ?? null, oneOffTargetType: gameStateRow.oneOffTargetType, + oneOffTargetTwo: convertedData.oneOffTargetTwo ?? null, + oneOffTargetTwoType: gameStateRow.oneOffTargetTwoType ?? null, resolved: convertedData.resolved ?? null, discardedCards: convertedData.discardedCards ?? null, }; diff --git a/api/helpers/game-states/validate-gamestate.js b/api/helpers/game-states/validate-gamestate.js index 029dcb42c..447c5e317 100644 --- a/api/helpers/game-states/validate-gamestate.js +++ b/api/helpers/game-states/validate-gamestate.js @@ -81,9 +81,12 @@ module.exports = { playedCard: gameState.playedCard ?? null, targetCard: gameState.targetCard ?? null, + targetCardTwo: gameState.targetCardTwo ?? null, oneOff: gameState.oneOff ?? null, oneOffTarget: gameState.oneOffTarget ?? null, oneOffTargetType: gameState.oneOffTargetType ?? null, + oneOffTargetTwo: gameState.oneOffTargetTwo ?? null, + oneOffTargetTwoType: gameState.oneOffTargetTwoType ?? null, resolved: gameState.resolved ?? null, }; diff --git a/api/helpers/game-states/validate-nine-targets.js b/api/helpers/game-states/validate-nine-targets.js new file mode 100644 index 000000000..e0f2e91ce --- /dev/null +++ b/api/helpers/game-states/validate-nine-targets.js @@ -0,0 +1,73 @@ +const TargetType = require('../../../utils/TargetType.json'); + +module.exports = { + friendlyName: 'Validate nine targets', + + description: + 'Checks whether a nine one-off requests a legal pair of targets. Returns null when the request is legal, ' + + 'or the error message (usually an i18n key) explaining why it is not. Shared by the one-off and ' + + 'seven-one-off validators so the rules for nines only live in one place.', + + inputs: { + /** + * @param { Object } requestedMove - the move being requested + * @param { String } requestedMove.targetId - first target's card id + * @param { 'point' | 'faceCard' | 'jack' } requestedMove.targetType - where the first target lives + * @param { String } requestedMove.targetIdTwo - second target's card id + * @param { 'point' | 'faceCard' | 'jack' } requestedMove.targetTypeTwo - where the second target lives + */ + requestedMove: { + type: 'ref', + description: 'Object containing data needed for the current move', + required: true, + }, + /** + * @param { Player } opponent - the player whose board holds both targets + */ + opponent: { + type: 'ref', + description: 'Player object whose board should contain both targets', + required: true, + }, + }, + sync: true, + fn: ({ requestedMove, opponent }, exits) => { + const { targetId, targetType, targetIdTwo, targetTypeTwo } = requestedMove; + + // A queen protects every other card its controller has, leaving itself the only legal + // target. A nine needs two, so any queen at all blocks the one-off outright. + if (opponent.faceCards.some(({ rank }) => rank === 12)) { + return exits.success('game.snackbar.oneOffs.nine.blockedByAnyQueen'); + } + + // Only the top jack of each stack can be targeted, so each point card offers at most one + const targetableJackCount = opponent.points.filter(({ attachments }) => attachments.length).length; + const legalTargetCount = opponent.points.length + opponent.faceCards.length + targetableJackCount; + if (legalTargetCount < 2) { + return exits.success('game.snackbar.oneOffs.nine.needTwoTargets'); + } + + const targetTypes = Object.values(TargetType); + if (!targetId || !targetIdTwo || !targetTypes.includes(targetType) || !targetTypes.includes(targetTypeTwo)) { + return exits.success('game.snackbar.oneOffs.nine.mustSelectTwoTargets'); + } + + if (targetId === targetIdTwo) { + return exits.success('game.snackbar.oneOffs.nine.duplicateTarget'); + } + + const { findTargetCard } = sails.helpers.gameStates; + for (const [ id, type ] of [ [ targetId, targetType ], [ targetIdTwo, targetTypeTwo ] ]) { + if (!findTargetCard(id, type, opponent)) { + return exits.success(`Can't find the ${id} on opponent's board`); + } + + // Jacks buried under another jack aren't targetable; only the top of the stack is + if (type === TargetType.jack && !sails.helpers.gameStates.isTopJack(id, opponent)) { + return exits.success('game.snackbar.oneOffs.onlyTopJack'); + } + } + + return exits.success(null); + }, +}; diff --git a/api/models/GameStateRow.js b/api/models/GameStateRow.js index f04539b53..4c0c2cc43 100644 --- a/api/models/GameStateRow.js +++ b/api/models/GameStateRow.js @@ -49,6 +49,12 @@ module.exports = { allowNull: true, columnType: 'text', }, + // The second card that was targeted (nines return two cards) + targetCardTwo: { + type: 'string', + allowNull: true, + columnType: 'text', + }, // Cards discarded for a 4 or 5 discardedCards: { type: 'json', @@ -138,6 +144,16 @@ module.exports = { allowNull: true, isIn: [ 'point', 'jack', 'faceCard' ], }, + // Second one-off target card (nines return two cards; null for every other one-off) + oneOffTargetTwo: { + type: 'string', + allowNull: true, + }, + oneOffTargetTwoType: { + type: 'string', + allowNull: true, + isIn: [ 'point', 'jack', 'faceCard' ], + }, // Twos twos: { type: 'json', diff --git a/docs/game-rules.md b/docs/game-rules.md index ca77309b3..eb7e35989 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**: Put an opponent's card on the field on top of the deck. Only the top Jack of a stack may be targeted. +- **Nine**: Return two of an opponent's cards on the field to the hand of whoever controlled them. Only the top Jack of a stack may be targeted, and a Nine cannot be played as a One-Off at all while the opponent has a Queen. ## Royals & Glasses Eights diff --git a/docs/game-state-api.md b/docs/game-state-api.md index 26cb0efe5..7e12fb468 100644 --- a/docs/game-state-api.md +++ b/docs/game-state-api.md @@ -51,6 +51,7 @@ Enum designating which kind of move was made. * jack * untargetedOneOff * targetedOneOff + * A 2 takes exactly one target (a royal or glasses eight). A 9 takes exactly **two** distinct targets, which may be any mix of point cards, royals/glasses, and top jacks; it returns both to the hand of whoever controlled them, unfrozen. Only the top jack of a stack is targetable. Any queen at all blocks a 9 (a queen protects everything else, leaving itself the only legal target, so two legal targets are impossible), whereas a 2 is only blocked by two or more queens. See `api/helpers/game-states/validate-nine-targets.js`. * counter * resolve * resolveThree (picking a card from the scrap) @@ -100,6 +101,7 @@ A GameState record represents one move made by a player and the resulting game s * phase: `GamePhase` \- What phase of a turn the game is currently in. Used to validate which next-moves are legal * playedCard: `String | null` the card that was played * targetCard: `String | null` the card that was targeted +* targetCardTwo: `String | null` the second card that was targeted. Only used by 9's, which return two cards * discardedCards: `Array` * p0Hand: `Array` * Specially formatted string representing the list of cards in p0’s hand. See [‘Game State Array\ Lists](#game-state-array\-lists) below for full explanation @@ -115,6 +117,8 @@ A GameState record represents one move made by a player and the resulting game s * scrap: `Array`: list of cards currently in the scrap * oneOff: `String | null`: the one-off currently in play, before resolution. Unset once resolved * oneOffTarget: `String | null`: target of the currently resolving one-off. Unset once one-off resolves +* oneOffTargetTwo: `String | null`: second target of the currently resolving one-off. Only set for 9's, which require two targets. Unset once one-off resolves +* oneOffTargetTwoType: `'point' | 'jack' | 'faceCard' | null`: where the second target is located * twos: `Array`: array of twos played as counters, currently on the stack, awaiting resolution * resolving: `String`: one-off being resolved in a secondary step e.g. a three while awaiting player selection from the scrap * gameId: ID \- FK to the games table @@ -155,7 +159,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 and can’t be played this turn. No effect currently freezes a card; retained pending the outcome of the topdeck-nines beta +* isFrozen: `Boolean` \- whether the card is currently frozen and can’t be played this turn. Nothing freezes cards as of rules version 3.0.0 (9's used to, but no longer do), so this is always false; the field is retained so the mechanic can be restored if the rules change back ## Player @@ -183,9 +187,12 @@ An uncompressed, object-oriented representation of a `GameStateRow`, created usi * deck: `Array`: Cards in the deck, in order (this removes the need for topCard and secondCard) * scrap: `Array` \- the cards currently in the scrap * oneOff: `Card | null` \- the current one-off that is waiting to resolve or be countered. Playing a oneOff removes the card from the player’s hand and puts it here while players counter back and forth until someone resolves, at which point the oneOff and all twos are scrapped and the effect resolves or fizzles base on the number of twos played -* oneOffTarget: `Card | null` \- when a 2 or 9 one-off is played, this describes which card is targeted by its effect +* oneOffTarget: `Card | null` \- when a 2 or 9 one-off is played, this describes which card is targeted by its effect. For a 9 this is the first of its two targets * oneOffTargetType `‘point’ | ‘faceCard’ | ‘jack’` * When the current oneOff is a 2/9, this describes where the target is located e.g. if the target is the 8 of hearts, is that a point card or face card (glasses) +* oneOffTargetTwo: `Card | null` \- a 9 returns two cards, so this describes its second target. Always null for every other one-off +* oneOffTargetTwoType `‘point’ | ‘faceCard’ | ‘jack’ | null` + * Where the 9's second target is located * twos: `Array` \- array of twos that have been played to counter the current oneOff * resolved: `Card | null` \- for a MoveType.RESOLVE move, this describes which oneOff just resolved or fizzled. This is not considered a “place” the cards can exist as the card is in the scrap at that point. Instead it’s just a description of which oneOff just resolved/fizzled. * gameId: ID \- FK to the games table @@ -199,6 +206,7 @@ An uncompressed, object-oriented representation of a `GameStateRow`, created usi * playerId: int * playedCard?: Object (the card that was played) * targetCard?: Object (the card that was targeted) +* targetCardTwo?: Object (the second card that was targeted, for 9's) * attachedToTarget?: Object (the point card that the target was attached to if the target was a jack) * gameOver: boolean * game: Object (full game state) @@ -217,6 +225,9 @@ An uncompressed, object-oriented representation of a `GameStateRow`, created usi * oneOffTarget * nullable, card object | player * oneOffTargetType: enum + * oneOffTargetTwo + * nullable, card object. The 9's second target + * oneOffTargetTwoType: enum * PLAYER * POINT\_CARD * FACE\_CARD diff --git a/src/plugins/sockets/inGameEvents.js b/src/plugins/sockets/inGameEvents.js index 52f126805..e77eee648 100644 --- a/src/plugins/sockets/inGameEvents.js +++ b/src/plugins/sockets/inGameEvents.js @@ -62,15 +62,10 @@ export async function handleInGameEvents(evData, newRoute = null) { gameStore.updateGame(evData.game); break; case SocketEvent.RESOLVE: - 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); + if (evData.game.lastEvent?.oneOff?.rank === 7) { + await gameStore.processSevens(evData.game); + } else { + gameStore.updateGame(evData.game); } break; case SocketEvent.SCUTTLE: diff --git a/src/routes/game/GameView.vue b/src/routes/game/GameView.vue index f9d4ae598..0f09c7ae5 100644 --- a/src/routes/game/GameView.vue +++ b/src/routes/game/GameView.vue @@ -222,9 +222,10 @@ class="field-point-container" > @@ -248,10 +250,11 @@ @@ -266,8 +269,8 @@ class="field-point-container" > @@ -289,9 +292,9 @@ @@ -400,7 +403,10 @@ :selected-card="selectedCard || cardSelectedFromDeck" :is-players-turn="gameStore.isPlayersTurn" :move-display-name="targetingMoveDisplayName" + :required-target-count="requiredTargetCount" + :selected-target-count="selectedNineTargets.length" @cancel="clearSelection" + @confirm="submitNineOneOff" /> @@ -466,6 +472,8 @@ export default { targeting: false, targetingMoveName: null, targetingMoveDisplayName: null, + // Targets chosen so far for a multi-target one-off (nines): { id, targetType, pointId } + selectedNineTargets: [], showFourDialog: false, topCardIsSelected: false, secondCardIsSelected: false, @@ -542,18 +550,7 @@ export default { /////////////////////////// // Transition Directions // /////////////////////////// - /** - * 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 - * stealing goes back to its owner in the same render. - */ - topdeckZone() { - return this.gameStore.topdeckedCardZone; - }, playerPointsTransition() { - if (this.topdeckZone === 'playerPoints') { - return this.toDeckTransition('player', 'points'); - } switch (this.gameStore.lastEventChange) { case 'resolve': // Different one-offs cause points to move in different directions @@ -562,10 +559,9 @@ export default { case 2: case 6: return Transitions.SLIDE_UP; - // 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 + // Nines return their targets to the hand of whoever controlled them case 9: - return Transitions.SLIDE_UP; + return Transitions.SLIDE_DOWN; default: return Transitions.SLIDE_DOWN_LEFT; } @@ -579,9 +575,11 @@ export default { } }, playerFaceCardsTransition() { - if (this.topdeckZone === 'playerFaceCards') { - return this.toDeckTransition('player', 'faceCards'); + // A face card returned by a nine slides down to the player's hand + if (this.gameStore.lastEventChange === 'resolve' && this.gameStore.lastEventOneOffRank === 9) { + 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; @@ -591,9 +589,6 @@ export default { return Transitions.SLIDE_DOWN_LEFT; }, opponentPointsTransition() { - if (this.topdeckZone === 'opponentPoints') { - return this.toDeckTransition('opponent', 'points'); - } switch (this.gameStore.lastEventChange) { // Jacks cause point cards to switch control (from/towards player) case 'jack': @@ -608,10 +603,9 @@ export default { case 2: case 6: return Transitions.SLIDE_DOWN; - // Nines move the target off the board entirely (to the deck), or hand a - // jacked point card back down to the player + // Nines return their targets to the hand of whoever controlled them case 9: - return Transitions.SLIDE_DOWN; + return Transitions.SLIDE_UP; default: return Transitions.SLIDE_UP_DOWN; } @@ -621,8 +615,9 @@ export default { } }, opponentFaceCardsTransition() { - if (this.topdeckZone === 'opponentFaceCards') { - return this.toDeckTransition('opponent', 'faceCards'); + // A face card returned by a nine slides up to the opponent's hand + if (this.gameStore.lastEventChange === 'resolve' && this.gameStore.lastEventOneOffRank === 9) { + return Transitions.SLIDE_UP; } // Playing from the deck if (this.gameStore.lastEventChange === 'sevenFaceCard') { @@ -633,17 +628,18 @@ export default { return Transitions.SLIDE_UP_DOWN; }, /** - * Jacks ride along with the point row they sit on, except when the jack itself is the - * card being topdecked -- then it leaves for the deck while its point card goes home. + * Jacks ride along with the point row they sit on, except when a nine returns the jack + * itself -- then it heads for its controller's hand while the point card it was stealing + * goes back to the other player in the same render. */ playerJacksTransition() { - return this.topdeckZone === 'playerJacks' - ? this.toDeckTransition('player', 'points') + return this.gameStore.lastEventChange === 'resolve' && this.gameStore.lastEventOneOffRank === 9 + ? Transitions.SLIDE_DOWN : this.playerPointsTransition; }, opponentJacksTransition() { - return this.topdeckZone === 'opponentJacks' - ? this.toDeckTransition('opponent', 'points') + return this.gameStore.lastEventChange === 'resolve' && this.gameStore.lastEventOneOffRank === 9 + ? Transitions.SLIDE_UP : this.opponentPointsTransition; }, ////////////////// @@ -656,7 +652,7 @@ export default { return this.t(this.gameStore.isPlayersTurn ? 'game.turn.yourTurn' : 'game.turn.opponentTurn'); }, validScuttleIds() { - const selectedCard = this.gameStore.resolvingSeven ? this.cardSelectedFromDeck : this.selectedCard; + const selectedCard = this.cardBeingPlayed; if (!selectedCard) { return []; } @@ -691,7 +687,7 @@ export default { if (!this.gameStore.isPlayersTurn) { return []; } - const selectedCard = this.gameStore.resolvingSeven ? this.cardSelectedFromDeck : this.selectedCard; + const selectedCard = this.cardBeingPlayed; if (!selectedCard) { return []; } @@ -701,18 +697,38 @@ export default { case 'jack': return this.gameStore.opponent.points.map((validTarget) => validTarget.id); case 'targetedOneOff': { - // Twos and nines can target face cards - let res = [ ...this.validFaceCardTargetIds ]; - // Nines can additionally target points if opponent has no queens - if (selectedCard.rank === 9 && this.gameStore.opponentQueenCount === 0) { - res = [ ...res, ...this.gameStore.opponent.points.map((validTarget) => validTarget.id) ]; + // A nine needs two targets, and any queen leaves itself as the only legal one, + // so a single queen blocks nines entirely + if (selectedCard.rank === 9) { + if (this.gameStore.opponentQueenCount > 0) { + return []; + } + return [ + ...this.validFaceCardTargetIds, + ...this.gameStore.opponent.points.map((validTarget) => validTarget.id), + ]; } - return res; + // Twos can only target face cards + return [ ...this.validFaceCardTargetIds ]; } default: return []; } }, + // The card being played, whether it came from hand or off the top of the deck via a seven + cardBeingPlayed() { + return this.gameStore.resolvingSeven ? this.cardSelectedFromDeck : this.selectedCard; + }, + // Nines return two cards, so they need two targets chosen before the move is sent + isTargetingNineOneOff() { + return this.targetingMoveName === 'targetedOneOff' && this.cardBeingPlayed?.rank === 9; + }, + requiredTargetCount() { + return this.isTargetingNineOneOff ? 2 : 1; + }, + selectedNineTargetIds() { + return this.selectedNineTargets.map(({ id }) => id); + }, // Sevens playingFromDeck() { return this.gameStore.playingFromDeck; @@ -772,33 +788,11 @@ 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); - }, - /** - * 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'); }, clearOverlays() { + this.selectedNineTargets = []; this.targeting = false; }, clearSelection() { @@ -951,7 +945,11 @@ export default { .catch(this.handleError); } }, - playTargetedOneOff(targetIndex, targetType) { + /** + * Resolves a clicked board position into a target descriptor, or null if nothing is there. + * Negative indices address the top jack attached to opponent.points[-targetIndex - 1]. + */ + resolveTargetDescriptor(targetIndex, targetType) { let target; let jackedPointId; switch (targetType) { @@ -970,18 +968,57 @@ export default { } break; } - if (!target) { + return target ? { id: target.id, targetType, pointId: jackedPointId } : null; + }, + playTargetedOneOff(targetIndex, targetType) { + const descriptor = this.resolveTargetDescriptor(targetIndex, targetType); + if (!descriptor) { this.handleError('lobby.error.fallback'); return; } + + // Nines accumulate two targets and are sent by the confirm button; every other + // targeted one-off still fires on the first click + if (this.isTargetingNineOneOff) { + this.toggleNineTarget(descriptor); + return; + } + + this.requestTargetedOneOff([ descriptor ]); + }, + /** Adds a target, or removes it when it was already chosen. Ignores clicks past the limit. */ + toggleNineTarget(descriptor) { + const existingIndex = this.selectedNineTargets.findIndex(({ id }) => id === descriptor.id); + if (existingIndex !== -1) { + this.selectedNineTargets.splice(existingIndex, 1); + return; + } + if (this.selectedNineTargets.length >= this.requiredTargetCount) { + return; + } + this.selectedNineTargets.push(descriptor); + }, + submitNineOneOff() { + if (this.selectedNineTargets.length !== this.requiredTargetCount) { + return; + } + this.requestTargetedOneOff(this.selectedNineTargets); + }, + /** Sends a targeted one-off with one target (2's) or two (9's) */ + requestTargetedOneOff([ target, targetTwo ]) { + const targets = { + targetId: target.id, + pointId: target.pointId, + targetType: target.targetType, + ...(targetTwo && { targetIdTwo: targetTwo.id, targetTypeTwo: targetTwo.targetType }), + }; + if (this.gameStore.resolvingSeven) { const deckIndex = this.topCardIsSelected ? 0 : 1; this.gameStore .requestPlayTargetedOneOffSeven({ cardId: this.cardSelectedFromDeck.id, - targetId: target.id, - pointId: jackedPointId, - targetType, + ...targets, index: deckIndex, }) .then(this.clearSelection) @@ -990,9 +1027,7 @@ export default { this.gameStore .requestPlayTargetedOneOff({ cardId: this.selectedCard.id, - targetId: target.id, - pointId: jackedPointId, - targetType, + ...targets, }) .then(this.clearSelection) .catch(this.handleError); diff --git a/src/routes/game/components/GameCard.vue b/src/routes/game/components/GameCard.vue index a3cad7b66..1310f6855 100644 --- a/src/routes/game/components/GameCard.vue +++ b/src/routes/game/components/GameCard.vue @@ -3,6 +3,7 @@ class="mx-1 player-card" :class="{ selected: isSelected, + 'selected-target': isSelectedTarget, glasses: isGlasses, jack: isJack, frozen: isFrozen, @@ -18,6 +19,16 @@ aria-hidden="false" role="img" /> + attachments.length).length; + return points.length + faceCards.length + jackTargets; + }, // Determines which disabled text to display disabledText() { if (this.playingFromDeck && !this.cardSelectedFromDeck) { @@ -178,31 +187,30 @@ export default { let oneOffDisabled = this.allMovesAreDisabled; let oneOffDisabledExplanation = this.disabledText; if (!this.allMovesAreDisabled) { - if (this.opponentQueenCount >= 2) { - oneOffDisabled = true; - oneOffDisabledExplanation = this.t('game.moves.disabledMove.multipleQueens', { rank: this.selectedCard.rank }); - } else { - let validTargetExists; - // Twos - if (this.selectedCard.rank === 2) { + // Twos are blocked by two or more queens; nines are blocked by any queen at all, + // since a lone queen is the only legal target and a nine needs two of them + if (this.selectedCard.rank === 2) { + if (this.opponentQueenCount >= 2) { + oneOffDisabled = true; + oneOffDisabledExplanation = this.t('game.moves.disabledMove.multipleQueens', { + rank: this.selectedCard.rank, + }); + } else { const numOpFaceCards = this.gameStore.opponent.faceCards.length; const numOpJacks = this.gameStore.opponent.points.reduce((jackCount, pointCard) => { return jackCount + pointCard.attachments.length; }, 0); - const numTotalTargets = numOpFaceCards + numOpJacks; - validTargetExists = numTotalTargets >= 1; - if (!validTargetExists) { + if (numOpFaceCards + numOpJacks < 1) { oneOffDisabled = true; oneOffDisabledExplanation = this.t('game.moves.disabledMove.noRoyals'); } - } else { - const numValidTargets = - this.gameStore.opponent.points.length + this.gameStore.opponent.faceCards.length; - if (numValidTargets === 0) { - oneOffDisabled = true; - oneOffDisabledExplanation = this.t('game.moves.disabledMove.noRoyalsOrPoints'); - } } + } else if (this.opponentQueenCount >= 1) { + oneOffDisabled = true; + oneOffDisabledExplanation = this.t('game.moves.disabledMove.queenBlocksNine'); + } else if (this.numValidNineTargets < 2) { + oneOffDisabled = true; + oneOffDisabledExplanation = this.t('game.moves.disabledMove.nineNeedsTwoTargets'); } } return { diff --git a/src/routes/game/components/TargetSelectionOverlay.vue b/src/routes/game/components/TargetSelectionOverlay.vue index 1b2d83867..941a3949c 100644 --- a/src/routes/game/components/TargetSelectionOverlay.vue +++ b/src/routes/game/components/TargetSelectionOverlay.vue @@ -7,16 +7,36 @@ @click="$emit('cancel')" />
-

Choose {{ moveDisplayName }} Target

- - Cancel - +

+ Choose {{ requiredTargetCount }} {{ moveDisplayName }} Targets ({{ selectedTargetCount }}/{{ + requiredTargetCount + }}) +

+

+ Choose {{ moveDisplayName }} Target +

+
+ + Cancel + + + Confirm + +
1; + }, + allTargetsChosen() { + return this.selectedTargetCount === this.requiredTargetCount; + }, }, - emits: [ 'cancel' ], }; @@ -68,4 +105,56 @@ export default { #target-selection-header-wrapper { width: 100%; } + +/* Draws the eye once both targets are chosen, mirroring BaseDialog's .dialog-activator fab */ +.confirm-targets:not(:disabled) { + position: relative; + overflow: hidden; + animation: pulse-glow 2s infinite ease-in-out; + + &::before { + content: ''; + position: absolute; + top: 0; + left: -150%; + width: 200%; + height: 100%; + background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.4) 50%, transparent 100%); + animation: strong-shimmer 4s infinite; + z-index: 1; + } + + &:hover::before { + animation: strong-shimmer 2s infinite; + } + + @media (prefers-reduced-motion: reduce) { + & { + animation: none; + + &::before { + animation: none; + } + } + } +} + +@keyframes pulse-glow { + 0%, + 100% { + box-shadow: 0 0 15px rgba(225, 48, 108, 0.6); + } + 50% { + box-shadow: 0 0 35px rgba(225, 48, 108, 1); + } +} + +@keyframes strong-shimmer { + 0% { + transform: translateX(-150%) skewX(-20deg); + } + 100% { + transform: translateX(150%) skewX(-20deg); + } +} diff --git a/src/routes/game/components/dialogs/GameDialogs.vue b/src/routes/game/components/dialogs/GameDialogs.vue index ef63e3daf..f4ede40e5 100644 --- a/src/routes/game/components/dialogs/GameDialogs.vue +++ b/src/routes/game/components/dialogs/GameDialogs.vue @@ -4,6 +4,7 @@ :model-value="showCounterDialog" :one-off="gameStore.oneOff" :target="gameStore.oneOffTarget" + :target-two="gameStore.oneOffTargetTwo" :twos-in-hand="twosInHand" :twos-played="twosPlayed" @resolve="resolve" @@ -16,6 +17,7 @@ :player-two-count="playerTwoCount" :twos-played="twosPlayed" :target="gameStore.oneOffTarget" + :target-two="gameStore.oneOffTargetTwo" @resolve="resolve" /> diff --git a/src/routes/game/components/dialogs/components/CannotCounterDialog.vue b/src/routes/game/components/dialogs/components/CannotCounterDialog.vue index 18b6530cf..a446dd976 100644 --- a/src/routes/game/components/dialogs/components/CannotCounterDialog.vue +++ b/src/routes/game/components/dialogs/components/CannotCounterDialog.vue @@ -13,6 +13,9 @@ {{ t(`game.dialogs.counterDialogs.target`) + t(`global.your`) }} +
@@ -40,6 +43,7 @@ /> +
{{ t(`game.dialogs.counterDialogs.cannotCounter`) + reason }}. @@ -92,6 +96,11 @@ export default { type: Object, default: null, }, + // Nines return two cards, so a second target may be at stake + targetTwo: { + type: Object, + default: null, + }, opponentQueenCount: { type: Number, default: 0, diff --git a/src/routes/game/components/dialogs/components/ChooseWhetherToCounterDialog.vue b/src/routes/game/components/dialogs/components/ChooseWhetherToCounterDialog.vue index 4c86b9b04..e1ab4d3aa 100644 --- a/src/routes/game/components/dialogs/components/ChooseWhetherToCounterDialog.vue +++ b/src/routes/game/components/dialogs/components/ChooseWhetherToCounterDialog.vue @@ -13,6 +13,9 @@ {{ `${t('game.dialogs.counterDialogs.target')} ${t('global.your')}` }} +
@@ -40,6 +43,7 @@ /> +
{{ t('game.dialogs.counterDialogs.playTwo') }} @@ -93,6 +97,11 @@ export default { type: Object, default: null, }, + // Nines return two cards, so a second target may be at stake + targetTwo: { + type: Object, + default: null, + }, twosPlayed: { type: Array, required: true, diff --git a/src/routes/home/components/announcementDialog/data/announcementData.js b/src/routes/home/components/announcementDialog/data/announcementData.js index a5ddcae4b..029687f76 100644 --- a/src/routes/home/components/announcementDialog/data/announcementData.js +++ b/src/routes/home/components/announcementDialog/data/announcementData.js @@ -1,14 +1,14 @@ // import { Card } from '../../../../../../tests/e2e/fixtures/cards'; export const announcementData = { - id: 'topdeckNines2026Announcement', + id: 'ninesBuff2026Announcement', activatorText: 'announcement.activatorText', title: 'announcement.title', displayCards: [ { suit: 3, rank: 9 }, - { suit: 2, rank: 9 }, + { suit: 0, rank: 9 }, ], - startTime: '2026-09-16', + startTime: '2026-09-19', endTime: '2027-01-05', announcementText: [ { @@ -17,13 +17,7 @@ export const announcementData = { }, { heading: 'announcement.heading2', - paragraph: [ - { text: 'announcement.paragraph2' }, - { - text: 'announcement.patreonLink', - url: 'https://www.patreon.com/cuttle/posts/help-refine-4-9-169762967', - }, - ], + paragraph: [ { text: 'announcement.paragraph2' } ], }, ], }; diff --git a/src/sass/transitions.scss b/src/sass/transitions.scss index d6b063f0a..aa2cff569 100644 --- a/src/sass/transitions.scss +++ b/src/sass/transitions.scss @@ -139,37 +139,3 @@ 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. -// -// Only `-leave-active` is positioned absolutely, so the cards left behind reflow into the -// gap; `-move` has to keep its place in the flow to slide there. That difference is the -// reason the two cannot share one rule. -$to-deck-offsets: ( - // xs: the deck sits below the point rows - 'down': translateY(96px), - // xs: and below/left of the face card rows - 'down-left': translate(-96px, 96px), - // above xs: the deck is up and to the left of the player's half - 'up-left': translate(-96px, -96px), - // above xs: and straight left of the opponent's half - 'left': translateX(-96px), -); - -@each $direction, $offset in $to-deck-offsets { - .to-deck-#{$direction}-leave-active { - position: absolute; - transition: opacity var(--duration-slow), transform var(--duration-slow); - } - - .to-deck-#{$direction}-move { - transition: transform var(--duration-slow); - } - - .to-deck-#{$direction}-leave-to { - opacity: 0; - transform: $offset; - } -} diff --git a/src/stores/game.js b/src/stores/game.js index 06c3b820a..02a6d5f85 100644 --- a/src/stores/game.js +++ b/src/stores/game.js @@ -95,6 +95,8 @@ export const useGameStore = defineStore('game', () => { const twos = ref([]); const oneOff = ref(null); const oneOffTarget = ref(null); + // Nines return two cards, so they carry a second target through countering + const oneOffTargetTwo = ref(null); const isRanked = ref(false); // Threes @@ -104,13 +106,6 @@ 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); - // 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); @@ -274,6 +269,7 @@ export const useGameStore = defineStore('game', () => { twos.value = newGame.twos?.map((card) => createGameCard(card)) ?? twos.value; oneOff.value = createGameCard(newGame.oneOff) ?? null; oneOffTarget.value = createGameCard(newGame.oneOffTarget) ?? null; + oneOffTargetTwo.value = createGameCard(newGame.oneOffTargetTwo) ?? null; isRanked.value = newGame.isRanked ?? isRanked.value; currentMatch.value = newGame.currentMatch ?? currentMatch.value; p0Rematch.value = newGame.p0Rematch ?? null; @@ -311,12 +307,11 @@ export const useGameStore = defineStore('game', () => { twos.value = []; oneOff.value = null; oneOffTarget.value = null; + oneOffTargetTwo.value = null; isRanked.value = false; lastEventPlayerChoosing.value = false; lastEventChange.value = null; lastEventOneOffRank.value = null; - topdeckedCard.value = null; - topdeckedCardZone.value = null; lastEventPlayedBy.value = null; lastEventThreeTarget.value = null; gameIsOver.value = false; @@ -380,95 +375,6 @@ export const useGameStore = defineStore('game', () => { await sleep(1000); updateGame(game); } - /** - * 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; - } - - /** - * 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. - * - * 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) { - updateGame(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. - topdeckedCardZone.value = findFieldZone(targetCard.id); - topdeckedCard.value = targetCard; - await sleep(1000); - - // 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); - } - async function processThrees(chosenCard, game) { phase.value = GamePhase.MAIN; const three = game.resolved; @@ -712,9 +618,18 @@ export const useGameStore = defineStore('game', () => { await makeSocketRequest('untargetedOneOff', { moveType, cardId: cardIdArg }); return Promise.resolve(); } - async function requestPlayTargetedOneOff({ cardId, targetId, pointId, targetType }) { + async function requestPlayTargetedOneOff({ cardId, targetId, pointId, targetType, targetIdTwo, targetTypeTwo }) { const moveType = MoveType.ONE_OFF; - await makeSocketRequest('targetedOneOff', { moveType, cardId, targetId, pointId, targetType }); + await makeSocketRequest('targetedOneOff', { + moveType, + cardId, + targetId, + pointId, + targetType, + // Nines return two cards, so they send a second target + targetIdTwo, + targetTypeTwo, + }); } async function requestPlayJack({ cardId, targetId }) { const moveType = MoveType.JACK; @@ -765,13 +680,24 @@ export const useGameStore = defineStore('game', () => { async function requestPlayOneOffSeven({ cardId, index }) { await makeSocketRequest('seven/untargetedOneOff', { moveType: MoveType.SEVEN_ONE_OFF, cardId, index }); } - async function requestPlayTargetedOneOffSeven({ cardId, index, targetId, pointId, targetType }) { + async function requestPlayTargetedOneOffSeven({ + cardId, + index, + targetId, + pointId, + targetType, + targetIdTwo, + targetTypeTwo, + }) { await makeSocketRequest('seven/targetedOneOff', { moveType: MoveType.SEVEN_ONE_OFF, cardId, targetId, pointId, targetType, + // Nines return two cards, so they send a second target + targetIdTwo, + targetTypeTwo, index, }); } @@ -827,13 +753,12 @@ export const useGameStore = defineStore('game', () => { secondCard, oneOff, oneOffTarget, + oneOffTargetTwo, isRanked, lastEventPlayerChoosing, lastEventThreeTarget, lastEventChange, lastEventOneOffRank, - topdeckedCard, - topdeckedCardZone, lastEventPlayedBy, gameIsOver, winnerPNum, @@ -887,7 +812,6 @@ export const useGameStore = defineStore('game', () => { setGameOver, setRematch, processScuttle, - processNines, processThrees, processFours, processFives, diff --git a/src/translations/de.json b/src/translations/de.json index 2b8915763..59846025f 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -221,7 +221,9 @@ "emptyDeck": "Kann nicht mit leerem Deck gespielt werden", "multipleQueens": "Du kannst keine {rank} spielen, während dein Gegner zwei oder mehr Damen hat", "noRoyals": "Es gibt keine Könige oder Damen als Ziel", - "noRoyalsOrPoints": "Es gibt keine Punktkarten oder Könige als Ziel" + "noRoyalsOrPoints": "Es gibt keine Punktkarten oder Könige als Ziel", + "queenBlocksNine": "Du kannst keine Neun spielen, während dein Gegner eine Dame hat", + "nineNeedsTwoTargets": "Eine Neun braucht zwei Karten als Ziel" }, "points": { "displayName": "Punkte", @@ -241,7 +243,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": "Lege die Zielkarte oben auf den Nachziehstapel", + "9": "Gib zwei Zielkarten zurück in die Hand ihres Besitzers", "10": "Kein Effekt", "11": "Spiele auf eine Ziel-Punktkarte, um sie zu stehlen", "12": "Deine anderen Karten können nur durch Abwerfaktionen angegriffen werden", @@ -295,7 +297,7 @@ "cardFrozen": "Diese Karte ist eingefroren! Du musst einen Zug warten, um sie zu spielen.", "playFromHand": "Du kannst nur eine Karte spielen, die in deiner Hand ist", "blockedByQueen": "Die Dame deines Gegners verhindert, dass du seine anderen Karten anvisierst", - "blockedByMultipleQueens": "Du kannst kein Ziel-One-Off (Zwei, Neun) spielen, wenn dein Gegner mehr als eine Dame hat", + "blockedByMultipleQueens": "Du kannst keine Zwei als Ziel-One-Off spielen, wenn dein Gegner mehr als eine Dame hat", "notInMainPhase": "Du kannst das nur in der Hauptphase tun" }, "draw": { @@ -363,7 +365,13 @@ "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" }, - "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", + "nine": { + "blockedByAnyQueen": "Du kannst keine Neun als One-Off spielen, während dein Gegner eine Dame hat", + "needTwoTargets": "Dein Gegner braucht mindestens zwei Karten, die du mit einer Neun anvisieren kannst", + "mustSelectTwoTargets": "Eine Neun muss genau zwei Karten anvisieren", + "duplicateTarget": "Eine Neun muss zwei verschiedene Karten anvisieren" + } } } }, @@ -462,7 +470,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": "Lege eine Karte vom Spielfeld deines Gegners oben auf den Nachziehstapel.", + "nineDescription": "Gib zwei Karten vom Spielfeld deines Gegners auf dessen Hand zurück. Sie dürfen sofort wieder gespielt werden. Du kannst eine Neun nicht als One-Off spielen, während dein Gegner eine Dame hat.", "ten": "Zehn", "tenDescription": "Kein Effekt." }, @@ -491,7 +499,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 vom Spielfeld deines Gegners oben auf den Nachziehstapel zu legen." + "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 und kostet keinen Zug. Alternativ kannst du weiterhin einen Neuner für seinen ursprünglichen Einmal-Effekt in deinem Zug spielen (dein Zug wird dafür genutzt), um zwei Karten auf die Hand ihres Besitzers zurückzugeben. In beiden Fällen dürfen die zurückgegebenen Karten im nächsten Zug ihres Besitzers erneut gespielt werden." }, "faq": { "title": "FAQ", @@ -587,13 +595,12 @@ } }, "announcement": { - "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": "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" + "activatorText": "Neunen werden gestärkt!", + "title": "Regel-Beta: Neunen geben zwei Karten zurück", + "heading": "Neunen geben jetzt zwei Karten zurück", + "paragraph": "Eine Neun ist eine der höchsten Karten im Deck, aber ihr One-Off war immer das nischigste und schwächste. Deshalb stärken wir es: Ein Neun-One-Off gibt jetzt zwei Karten vom Spielfeld deines Gegners auf die Hand ihres Besitzers zurück — in beliebiger Mischung aus Punktkarten, Royals und Buben — und die zurückgegebenen Karten sind nicht mehr eingefroren, können also sofort wieder gespielt werden. Da eine Dame alles andere schützt, was dein Gegner kontrolliert, kann ein Neun-One-Off überhaupt nicht gespielt werden, solange er eine Dame hat.", + "heading2": "Test während der Spades-Saison", + "paragraph2": "Das ist eine Beta. Wir lassen sie die ganze Spades-Saison laufen, damit alle sie in echten Spielen ausprobieren können, und stellen sie dann gegen Ende des Jahres in einer Community-Abstimmung zur Wahl, ob sie bleibt." }, "profile": { "myGames": "Meine Spiele", diff --git a/src/translations/en.json b/src/translations/en.json index ee2cb49a0..a44334d96 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -221,7 +221,9 @@ "emptyDeck": "Can't be played with empty deck", "multipleQueens": "You can't play a {rank} while your opponent has two or more queens", "noRoyals": "There are no Royals to target", - "noRoyalsOrPoints": "There are no point cards or Royals to target" + "noRoyalsOrPoints": "There are no point cards or Royals to target", + "queenBlocksNine": "You can't play a Nine while your opponent has a Queen", + "nineNeedsTwoTargets": "A Nine needs two cards to target" }, "points": { "displayName": "Points", @@ -241,7 +243,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": "Put target card on top of the deck", + "9": "Return two target cards to their controller's hand", "10": "No effect", "11": "Play on top of target point card to steal it", "12": "Your other cards may only be targeted by scuttles", @@ -296,7 +298,7 @@ "cardFrozen": "That card is frozen! You must wait a turn to play it.", "playFromHand": "You can only play a card that is in your hand", "blockedByQueen": "Your opponent's queen prevents you from targeting their other cards", - "blockedByMultipleQueens": "You cannot play a Targeted One-Off (Two, Nine) when your opponent has more than one Queen", + "blockedByMultipleQueens": "You cannot play a Two as a Targeted One-Off when your opponent has more than one Queen", "notInMainPhase": "You can only do that in the main phase" }, "draw": { @@ -364,7 +366,13 @@ "mustBeDoubleJacks": "You can't discard from a seven unless both cards are jacks", "jacksMustBeUnplayable": "You can't discard because the jacks can be played" }, - "onlyTopJack": "You can only target the top Jack of a stack" + "onlyTopJack": "You can only target the top Jack of a stack", + "nine": { + "blockedByAnyQueen": "You cannot play a Nine as a One-Off while your opponent has a Queen", + "needTwoTargets": "Your opponent needs at least two cards you can target for a Nine", + "mustSelectTwoTargets": "A Nine must target exactly two cards", + "duplicateTarget": "A Nine must target two different cards" + } } } }, @@ -463,7 +471,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": "Put a card from your opponent's field on top of the deck.", + "nineDescription": "Return two cards from your opponent's field to their hand. They can play them again right away. You cannot play a Nine one-off while your opponent has a Queen.", "ten": "Ten", "tenDescription": "No effect." }, @@ -492,7 +500,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 put a card from your opponent's field on top of the deck." + "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. Alternatively, you may still play a Nine for its original one-off effect on your turn (taking your turn to do so) to return two cards to their controller's hand. Either way, the returned cards may be played again on their owner's next turn." }, "faq": { "title": "FAQ", @@ -589,12 +597,11 @@ }, "announcement": { "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": "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" + "title": "Rules Beta: Nines Return Two Cards", + "heading": "Nines now return two cards", + "paragraph": "A Nine is one of the highest cards in the deck, but its one-off has always been the most niche and least impactful. So we're buffing it: a Nine one-off now returns two cards from your opponent's board to their controller's hand — any mix of point cards, Royals, and Jacks — and the returned cards are no longer frozen, so they can be played again right away. Because a Queen protects everything else your opponent controls, a Nine one-off can't be played at all while they have a Queen.", + "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 2718ed9bf..8d019ae1f 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -221,7 +221,9 @@ "emptyDeck": "No se puede jugar con el mazo vacío", "multipleQueens": "No puedes jugar un {rank} mientras tu rival tenga 2 o más Reinas", "noRoyals": "No hay Figuras para tomar como objetivo", - "noRoyalsOrPoints": "No hay cartas de puntos ni Figuras para tomar como objetivo" + "noRoyalsOrPoints": "No hay cartas de puntos ni Figuras para tomar como objetivo", + "queenBlocksNine": "No puedes jugar un Nueve mientras tu rival tenga una Reina", + "nineNeedsTwoTargets": "Un Nueve necesita dos cartas como objetivo" }, "points": { "displayName": "Puntos", @@ -241,7 +243,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": "Pon la carta objetivo encima del mazo", + "9": "Devuelve dos cartas objetivo a la mano de su controlador", "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)", @@ -295,7 +297,7 @@ "cardFrozen": "¡Esa carta está bloqueada! Debes esperar a tu turno para jugarla.", "playFromHand": "Solo puedes jugar una carta que se encuentre en tu mano", "blockedByQueen": "La Reina de tu oponente evita que puedas seleccionar como objetivo sus otras cartas", - "blockedByMultipleQueens": "No puedes jugar una carta de habilidad única con objetivo (Dos, Nueve) cuando tu oponente tiene más de una reina", + "blockedByMultipleQueens": "No puedes jugar un Dos como carta de habilidad única con objetivo cuando tu oponente tiene más de una reina", "notInMainPhase": "Solo puedes hacer eso en la fase principal" }, "draw": { @@ -363,7 +365,13 @@ "mustBeDoubleJacks": "No puedes descartar desde un siete a menos que ambas cartas sean jotas", "jacksMustBeUnplayable": "No puedes descartar porque las jotas se pueden jugar" }, - "onlyTopJack": "Solo puedes seleccionar la Jota superior de una pila" + "onlyTopJack": "Solo puedes seleccionar la Jota superior de una pila", + "nine": { + "blockedByAnyQueen": "No puedes jugar un Nueve como carta de habilidad única mientras tu rival tenga una Reina", + "needTwoTargets": "Tu rival necesita al menos dos cartas que puedas tomar como objetivo con un Nueve", + "mustSelectTwoTargets": "Un Nueve debe tomar como objetivo exactamente dos cartas", + "duplicateTarget": "Un Nueve debe tomar como objetivo dos cartas diferentes" + } } } }, @@ -462,7 +470,7 @@ "seven": "Siete", "sevenDescription": "Revela las dos primeras cartas del mazo a ambos jugadores y elige una para jugar como desees.", "nine": "Nueve", - "nineDescription": "Pon una carta del campo de tu rival encima del mazo.", + "nineDescription": "Devuelve dos cartas del campo de tu rival a su mano. Puede volver a jugarlas de inmediato. No puedes jugar un Nueve como carta de habilidad única mientras tu rival tenga una Reina.", "ten": "Diez", "tenDescription": "Sin efecto." }, @@ -491,7 +499,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 poner una carta del campo de tu rival encima del mazo." + "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. Alternativamente, aún puedes jugar un Nueve para obtener su efecto único original en tu turno (gastando tu turno para hacerlo), para devolver dos cartas a la mano de su controlador. En cualquier caso, las cartas devueltas pueden volver a jugarse en el siguiente turno de su propietario." }, "faq": { "title": "FAQ", @@ -588,12 +596,11 @@ }, "announcement": { "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": "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" + "title": "Beta de reglas: los Nueves devuelven dos cartas", + "heading": "Los Nueves ahora devuelven dos cartas", + "paragraph": "El Nueve es una de las cartas más altas del mazo, pero su habilidad única siempre ha sido la más específica y la menos impactante. Por eso la estamos mejorando: un Nueve como carta de habilidad única ahora devuelve dos cartas del campo de tu rival a la mano de su controlador — en cualquier combinación de cartas de puntos, Figuras y Jotas — y las cartas devueltas ya no quedan congeladas, así que pueden volver a jugarse de inmediato. Como una Reina protege todo lo demás que controla tu rival, no puedes jugar un Nueve como carta de habilidad única mientras tenga una Reina.", + "heading2": "En pruebas durante la temporada de Espadas", + "paragraph2": "Esto es una beta. La mantendremos durante toda la temporada de Espadas para que todos la prueben en partidas reales, y después la someteremos a una votación de la comunidad hacia el final del año para decidir si se queda." }, "profile": { "myGames": "Mis juegos", diff --git a/src/translations/fr.json b/src/translations/fr.json index 8d4bd0d5c..7628cc907 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -221,7 +221,9 @@ "emptyDeck": "On ne peut pas jouer avec la pioche vide", "multipleQueens": "Vous ne pouvez pas jouer un {rank} lorsque votre adversaire a deux ou plusieurs reines", "noRoyals": "Il n'y a pas de rois à cibler", - "noRoyalsOrPoints": "Il n'y a pas de cartes à points ni de rois à cibler" + "noRoyalsOrPoints": "Il n'y a pas de cartes à points ni de rois à cibler", + "queenBlocksNine": "Vous ne pouvez pas jouer un Neuf lorsque votre adversaire a une dame", + "nineNeedsTwoTargets": "Un Neuf a besoin de deux cartes à cibler" }, "points": { "displayName": "Points", @@ -241,7 +243,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": "Place la carte ciblée sur le dessus de la pioche", + "9": "L'adversaire reprend en main les deux cartes ciblées.", "10": "Aucune tactique.", "11": "Cible et vole une carte à points.", "12": "Protège vos cartes à points des attaques tactiques", @@ -295,7 +297,7 @@ "cardFrozen": "Cette carte est gelée ! Vous ne pourrez la jouer qu'au prochain tour.", "playFromHand": "Vous ne pouez jouer que des cartes de votre main.", "blockedByQueen": "La Dame de votre adversaire empêche ses autres cartes d'être ciblées.", - "blockedByMultipleQueens": "Impossible de jouer une capacité cible (2 ou 9) quand votre adversaire a plus d'une dame.", + "blockedByMultipleQueens": "Impossible de jouer la capacité cible du Deux quand votre adversaire a plus d'une dame.", "notInMainPhase": "Vous ne pouvez faire cela qu'en phase principale" }, "draw": { @@ -363,7 +365,13 @@ "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" }, - "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", + "nine": { + "blockedByAnyQueen": "Impossible de jouer la capacité du Neuf quand votre adversaire a une dame.", + "needTwoTargets": "Votre adversaire doit avoir au moins deux cartes que vous pouvez cibler avec un Neuf.", + "mustSelectTwoTargets": "Un Neuf doit cibler exactement deux cartes.", + "duplicateTarget": "Un Neuf doit cibler deux cartes différentes." + } } } }, @@ -462,7 +470,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": "Placez une carte du terrain de votre adversaire sur le dessus de la pioche.", + "nineDescription": "Ciblez deux cartes actives de votre adversaire pour les renvoyer dans sa main. Il peut les rejouer immédiatement. Vous ne pouvez pas jouer la capacité du Neuf quand votre adversaire a une dame.", "ten": "Dix", "tenDescription": "Aucun effet." }, @@ -491,7 +499,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é : la carte ciblée est placée sur le dessus de la pioche." + "rule5": "Le Neuf a une capacité supplémentaire. Il peut être joué pour contrer une carte figure jouée par un adversaire. Si vous jouez un Neuf à votre tour, son effet habituel est appliqué : l'adversaire reprend en main les deux cartes ciblées. Dans les deux cas, les cartes renvoyées peuvent être rejouées au prochain tour de leur propriétaire." }, "faq": { "title": "FAQ", @@ -587,13 +595,12 @@ } }, "announcement": { - "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": "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" + "activatorText": "Le Neuf est renforcé !", + "title": "Beta de règles : le Neuf renvoie deux cartes", + "heading": "Le Neuf renvoie désormais deux cartes", + "paragraph": "Le Neuf est l'une des cartes les plus fortes du jeu, mais sa capacité a toujours été la plus situationnelle et la moins puissante. Nous la renforçons donc : la capacité du Neuf renvoie maintenant deux cartes du terrain de votre adversaire dans la main de leur contrôleur — n'importe quelle combinaison de cartes à points, de figures et de valets — et les cartes renvoyées ne sont plus gelées, elles peuvent donc être rejouées immédiatement. Comme une dame protège tout ce que votre adversaire contrôle, vous ne pouvez pas jouer la capacité du Neuf tant qu'il a une dame.", + "heading2": "À l'essai pendant la saison Piques", + "paragraph2": "Il s'agit d'une beta. Nous la laisserons en place pendant toute la saison Piques pour que tout le monde 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 23cbecfe5..e3f676d54 100644 --- a/src/translations/ukr.json +++ b/src/translations/ukr.json @@ -221,7 +221,9 @@ "emptyDeck": "Не можна грати з порожньою колодою", "multipleQueens": "Ви не можете зіграти {rank}, якщо у вашого суперника є дві або більше королеви", "noRoyals": "Немає королівських карт для вибору", - "noRoyalsOrPoints": "Немає карт для очок або королівських карт для вибору" + "noRoyalsOrPoints": "Немає карт для очок або королівських карт для вибору", + "queenBlocksNine": "Ви не можете зіграти дев'ятку, якщо у вашого суперника є Королева", + "nineNeedsTwoTargets": "Дев'ятці потрібні дві карти для вибору" }, "points": { "displayName": "Очки", @@ -241,7 +243,7 @@ "6": "Знищити всі королівські карти та восьмірки окулярів", "7": "Зіграти одну з перших двох карт з колоди та повернути іншу назад (обидві відкриті)", "8": "Ваш суперник грає з відкритою рукою (його карти відкриті для вас)", - "9": "Покласти вибрану карту зверху колоди", + "9": "Поверніть дві вибрані карти в руку їхнього власника", "10": "Без ефекту", "11": "Зіграти поверх карти з очками, щоб вкрасти її", "12": "Ваші інші карти можуть бути вибрані лише для знищення", @@ -295,7 +297,7 @@ "cardFrozen": "Ця карта заморожена! Ви повинні зачекати хід, щоб зіграти її.", "playFromHand": "Ви можете зіграти тільки карту, яка є у вашій руці", "blockedByQueen": "Королева вашого суперника заважає вам націлювати інші карти суперника", - "blockedByMultipleQueens": "Ви не можете зіграти картку з націленням (Два, Дев’ять), коли у вашого суперника більше ніж одна Королева", + "blockedByMultipleQueens": "Ви не можете зіграти двійку як картку з націленням, коли у вашого суперника більше ніж одна Королева", "notInMainPhase": "Ви можете зробити це тільки в основній фазі" }, "draw": { @@ -363,7 +365,13 @@ "mustBeDoubleJacks": "Не можна відкидати з сімки, якщо обидві картки не є валетами", "jacksMustBeUnplayable": "Не можна відкидати, тому що валети можуть бути зіграні" }, - "onlyTopJack": "Ви можете вибрати лише верхнього Валета у стосі" + "onlyTopJack": "Ви можете вибрати лише верхнього Валета у стосі", + "nine": { + "blockedByAnyQueen": "Ви не можете зіграти дев'ятку як one-off, якщо у вашого суперника є Королева", + "needTwoTargets": "У вашого суперника має бути щонайменше дві карти, які ви можете вибрати для дев'ятки", + "mustSelectTwoTargets": "Дев'ятка має вибирати рівно дві карти", + "duplicateTarget": "Дев'ятка має вибирати дві різні карти" + } } } }, @@ -462,7 +470,7 @@ "seven": "Сім", "sevenDescription": "розкрийте та покажіть усім дві верхні карти з колоди, а потім оберіть одну та зіграйте нею тим способом, який ви оберете.", "nine": "Дев'ять", - "nineDescription": "покладіть будь-яку карту з поля вашого суперника зверху колоди.", + "nineDescription": "Поверніть дві карти з поля вашого суперника йому назад у руку. Він може зіграти ними одразу. Ви не можете зіграти дев'ятку як one-off, якщо у суперника є Королева.", "ten": "Десять", "tenDescription": "не має ефектів." }, @@ -491,7 +499,7 @@ "rule2": "До колоди додають двоє джокерів. Джокери рахуються як королі з ефектом \"Викрасти цільового короля\". Як і валети, вони можуть передавати контроль над королем від одного гравця до іншого.", "rule3": "Ліміт карт у руці зменшено до 7 (з 8).", "rule4": "Валети можна грати, щоб передати контроль над будь-якою карткою балів іншому гравцеві. Тому ви можете \"вкрасти\" бали у себе (або будь-кого) та передати їх своєму партнеру, потенційно вигравши свій хід його балами (їхня мета все одно залежить від кількості їх власних королів на полі).", - "rule5": "Дев'ятки можна зіграти для альтернативного подібного ефекту. Коли противник грає короля, ви можете негайно зіграти дев'ятку у відповідь, щоб повернути цю картку власнику до його руки, запобігаючи її ефекту. Це можна зробити поза чергою, і це не забирає ваш хід, але не перешкоджає власнику картки зіграти її на наступному ходу. Як альтернатива, ви все одно можете зіграти дев'ятку для її оригінального ефекту один раз на вашому ходу (використовуючи свій хід для цього), щоб покласти картку з поля вашого суперника зверху колоди." + "rule5": "Дев'ятки можна зіграти для альтернативного подібного ефекту. Коли противник грає короля, ви можете негайно зіграти дев'ятку у відповідь, щоб повернути цю картку власнику до його руки, запобігаючи її ефекту. Це можна зробити поза чергою, і це не забирає ваш хід. Як альтернатива, ви все одно можете зіграти дев'ятку для її оригінального ефекту один раз на вашому ходу (використовуючи свій хід для цього), щоб повернути дві картки в руку їхнього власника. У будь-якому разі повернуті картки можна зіграти вже на наступному ходу їхнього власника." }, "faq": { "title": "FAQ", @@ -588,12 +596,11 @@ }, "announcement": { "activatorText": "Дев'ятки стають сильнішими!", - "title": "Бета правил: дев'ятки кладуть на колоду", - "heading": "Дев'ятки тепер кладуть карти зверху колоди", - "paragraph": "Дев'ятка — одна з найстарших карт у колоді, але її одноразовий ефект завжди був найвужчим. Тепер замість повернення карти в руку власника дев'ятка кладе карту з поля вашого суперника зверху колоди — карту очок, фігуру або верхнього Валета у стосі. Наступним ходом вони візьмуть свою ж карту, тож дев'ятка коштує їм цілого ходу. Більше нічого не заморожується, а Дами захищають як і раніше.", - "heading2": "Перша з чотирьох проб сезону Пік", - "paragraph2": "Це бета-версія і перша з чотирьох варіацій правил, які ми тестуємо впродовж сезону Пік — два посилення дев'ятки та два четвірки. Наприкінці року проведемо опитування спільноти, щоб вирішити, що залишити. Повний контекст і обґрунтування всіх чотирьох див. тут:", - "patreonLink": "Допоможіть удосконалити 4 та 9" + "title": "Бета правил: дев'ятки повертають дві карти", + "heading": "Дев'ятки тепер повертають дві карти", + "paragraph": "Дев'ятка — одна з найстарших карт у колоді, але її one-off завжди був найвужчим і найслабшим. Тому ми його підсилюємо: тепер дев'ятка як one-off повертає дві карти з поля вашого суперника в руку їхнього власника — у будь-якому поєднанні карт очок, королівських карт і валетів — і повернуті карти більше не заморожуються, тож ними можна зіграти одразу. Оскільки Королева захищає все інше, що контролює ваш суперник, зіграти дев'ятку як one-off неможливо, поки в нього є Королева.", + "heading2": "Тестуємо протягом сезону Пік", + "paragraph2": "Це бета. Ми залишимо її на весь сезон Пік, щоб усі могли випробувати її в справжніх іграх, а потім наприкінці року проведемо опитування спільноти, щоб вирішити, чи вона залишиться." }, "profile": { "myGames": "Мої ігри", diff --git a/tests/e2e/fixtures/snackbarError.js b/tests/e2e/fixtures/snackbarError.js index 558915769..063df489a 100644 --- a/tests/e2e/fixtures/snackbarError.js +++ b/tests/e2e/fixtures/snackbarError.js @@ -10,6 +10,14 @@ const SnackBarError = { THREE_EMPTY_SCRAP: 'You can only play a 3 as a one-off if there are non-three cards in the scrap pile', FOUR_EMPTY_HAND: 'You cannot play a 4 as a one-off while your opponent has no cards in hand', EMPTY_DECK: 'You can\'t play that one-off unless there are cards in the deck', + NINE: { + BLOCKED_BY_ANY_QUEEN: 'You cannot play a Nine as a One-Off while your opponent has a Queen', + NEED_TWO_TARGETS: 'Your opponent needs at least two cards you can target for a Nine', + MUST_SELECT_TWO_TARGETS: 'A Nine must target exactly two cards', + DUPLICATE_TARGET: 'A Nine must target two different cards', + }, + // Shared by twos and nines -- both may only target the top jack of a stack + ONLY_TOP_JACK: 'You can only target the top Jack of a stack', } }; diff --git a/tests/e2e/specs/in-game/handLimit.spec.js b/tests/e2e/specs/in-game/handLimit.spec.js index 7253422b2..10fe758f0 100644 --- a/tests/e2e/specs/in-game/handLimit.spec.js +++ b/tests/e2e/specs/in-game/handLimit.spec.js @@ -410,16 +410,58 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { }); }); - describe('Passing and Hand Limit', () => { + describe('Nines 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', () => { - // 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. + it('Skips discarding when opponent nines player at the hand limit and player then plays points', () => { 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.NINE_OF_CLUBS, + ], + p1Points: [ Card.TEN_OF_CLUBS, Card.EIGHT_OF_CLUBS ], + p1FaceCards: [], + topCard: Card.TWO_OF_HEARTS, + }); + + // Opponent (P0) plays nine targeting both of the player's (P1's) point cards + cy.playTargetedOneOffOpponent( + Card.NINE_OF_SPADES, + Card.TEN_OF_CLUBS, + 'point', + Card.EIGHT_OF_CLUBS, + 'point', + ); + cy.get('#cannot-counter-dialog').should('be.visible') + .get('[data-cy=cannot-counter-resolve]') + .click(); + + // Player now has 9 cards (7 in hand + both returned point cards) — 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: [], @@ -431,19 +473,123 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { 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, + Card.FOUR_OF_CLUBS, + Card.FIVE_OF_CLUBS, + Card.SIX_OF_CLUBS, + Card.SEVEN_OF_CLUBS, Card.NINE_OF_CLUBS, + ], + p1Points: [ Card.TEN_OF_CLUBS, Card.EIGHT_OF_CLUBS ], + p1FaceCards: [], + topCard: Card.TWO_OF_HEARTS, + }); + + // Opponent (P0) plays nine targeting both of the player's (P1's) point cards + cy.playTargetedOneOffOpponent( + Card.NINE_OF_SPADES, + Card.TEN_OF_CLUBS, + 'point', + Card.EIGHT_OF_CLUBS, + 'point', + ); + cy.get('#cannot-counter-dialog').should('be.visible') + .get('[data-cy=cannot-counter-resolve]') + .click(); + + // Player now has 9 cards (7 in hand + both returned point cards) — 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.NINE_OF_CLUBS, + ], + p1Points: [ Card.TEN_OF_CLUBS, Card.EIGHT_OF_CLUBS ], + p1FaceCards: [], // Empty deck so the player can pass (rather than draw) on their turn deck: [], }); - // Opponent passes, handing the turn to the player who is over the limit - cy.passOpponent(); + // Opponent (P0) plays nine targeting both of the player's (P1's) point cards + cy.playTargetedOneOffOpponent( + Card.NINE_OF_SPADES, + Card.TEN_OF_CLUBS, + 'point', + Card.EIGHT_OF_CLUBS, + 'point', + ); + cy.get('#cannot-counter-dialog').should('be.visible') + .get('[data-cy=cannot-counter-resolve]') + .click(); - // Player has 9 cards and has not been asked to discard yet + // Player now has 9 cards (7 in hand + both returned point cards) — no immediate discard cy.get('[data-player-hand-card]').should('have.length', 9); cy.get('#discard-to-hand-limit-dialog').should('not.exist'); @@ -485,11 +631,10 @@ describe('Hand Limit — Discard to Hand Limit Phase', () => { p1FaceCards: [], deck: [], scrap: [ - // Discarded down to the hand limit + Card.NINE_OF_SPADES, 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 ab2bd6902..119517fa2 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 @@ -484,7 +484,7 @@ describe('Opponent playing SEVENS', () => { p0FaceCards: [], p1Hand: [], p1Points: [], - p1FaceCards: [ Card.KING_OF_HEARTS, Card.QUEEN_OF_CLUBS ], + p1FaceCards: [ Card.KING_OF_HEARTS, Card.KING_OF_SPADES ], topCard: Card.NINE_OF_DIAMONDS, secondCard: Card.TWO_OF_SPADES, }); @@ -499,31 +499,24 @@ describe('Opponent playing SEVENS', () => { // Waiting for opponent cy.get('#waiting-for-opponent-play-from-deck-scrim').should('be.visible'); - cy.playTargetedOneOffFromSevenOpponent(Card.NINE_OF_DIAMONDS, Card.QUEEN_OF_CLUBS, 'faceCard'); + // A nine needs two targets, and any queen would block it entirely, so it takes both kings + cy.playTargetedOneOffFromSevenOpponent( + Card.NINE_OF_DIAMONDS, + Card.KING_OF_HEARTS, + 'faceCard', + Card.KING_OF_SPADES, + 'faceCard', + ); 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: [], + p1Hand: [ Card.KING_OF_HEARTS, Card.KING_OF_SPADES ], 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: [], - p0FaceCards: [], - p1Hand: [ Card.QUEEN_OF_CLUBS ], - p1Points: [], - p1FaceCards: [ Card.KING_OF_HEARTS ], + p1FaceCards: [], scrap: [ Card.NINE_OF_DIAMONDS, Card.SEVEN_OF_CLUBS ], }); }); // End Opponent NINE from seven @@ -566,53 +559,45 @@ describe('Opponent playing SEVENS', () => { // Waiting for opponent cy.get('#waiting-for-opponent-play-from-deck-scrim').should('be.visible'); - cy.playTargetedOneOffFromSevenOpponent(Card.NINE_OF_CLUBS, Card.JACK_OF_CLUBS, 'jack'); + // The nine takes the jack and the king; the jacked point card is not a target, + // so it reverts to its owner's points + cy.playTargetedOneOffFromSevenOpponent( + Card.NINE_OF_CLUBS, + Card.JACK_OF_CLUBS, + 'jack', + Card.KING_OF_HEARTS, + 'faceCard', + ); // Player resolves 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: [], + p1Hand: [ Card.JACK_OF_CLUBS, Card.KING_OF_HEARTS ], p1Points: [], - p1FaceCards: [ Card.KING_OF_HEARTS ], + p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS, Card.SEVEN_OF_CLUBS ], }); - // 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 ], - 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 + // Nines no longer freeze what they return, so the jack can be replayed at once 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', 1); - - // 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-move-choice=jack]').should('not.have.class', 'v-card--disabled') + .click(); cy.get('[data-opponent-point-card=1-0]').click(); + cy.log('Successfully replayed the returned jack immediately'); assertGameState(1, { - p0Hand: [], - p0Points: [ Card.TEN_OF_DIAMONDS ], + p0Hand: [ Card.TEN_OF_DIAMONDS ], + p0Points: [], p0FaceCards: [], - p1Hand: [], + p1Hand: [ Card.KING_OF_HEARTS ], p1Points: [ Card.ACE_OF_CLUBS ], - p1FaceCards: [ Card.KING_OF_HEARTS ], + p1FaceCards: [], 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 5ac01dc4f..44f6a7513 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 @@ -827,7 +827,7 @@ describe('Playing SEVENS', () => { p0FaceCards: [], p1Hand: [], p1Points: [], - p1FaceCards: [ Card.KING_OF_HEARTS, Card.QUEEN_OF_CLUBS ], + p1FaceCards: [ Card.KING_OF_HEARTS, Card.KING_OF_CLUBS ], topCard: Card.NINE_OF_DIAMONDS, secondCard: Card.TWO_OF_SPADES, }); @@ -839,22 +839,25 @@ describe('Playing SEVENS', () => { .and('be.visible') .click(); cy.get('[data-move-choice=targetedOneOff]').click(); - // target queen of clubs - cy.get('[data-opponent-face-card=12-0]').find('.valid-move') + // A nine needs two targets, and any queen would block it entirely, so take both kings + cy.get('[data-opponent-face-card=13-2]').find('.valid-move') .click({ force: true }); // force because overlay itself is not techincally clickable + cy.get('[data-opponent-face-card=13-0]').find('.valid-move') + .click({ force: true }); + cy.get('[data-cy=confirm-targets]').should('not.be.disabled') + .click(); cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); // Opponent does not counter (resolves stack) 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: [], + p1Hand: [ Card.KING_OF_HEARTS, Card.KING_OF_CLUBS ], p1Points: [], - p1FaceCards: [ Card.KING_OF_HEARTS ], + p1FaceCards: [], scrap: [ Card.NINE_OF_DIAMONDS, Card.SEVEN_OF_CLUBS ], }); }); // End playing NINE from seven @@ -895,22 +898,26 @@ describe('Playing SEVENS', () => { .and('be.visible') .click(); cy.get('[data-move-choice=targetedOneOff]').click(); - // target jack of clubs + // Target the jack and the king. The jacked point card is not a target, so it + // reverts to its owner's points. cy.get('[data-opponent-face-card=11-0]').find('.valid-move') .click({ force: true }); // force b/c overlay itself is not technically clickable + cy.get('[data-opponent-face-card=13-2]').find('.valid-move') + .click({ force: true }); + cy.get('[data-cy=confirm-targets]').should('not.be.disabled') + .click(); cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); // Opponent does not counter (resolves stack) 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: [], + p1Hand: [ Card.JACK_OF_CLUBS, Card.KING_OF_HEARTS ], p1Points: [], - p1FaceCards: [ Card.KING_OF_HEARTS ], + p1FaceCards: [], scrap: [ Card.NINE_OF_DIAMONDS, Card.SEVEN_OF_CLUBS ], }); }); // End playing NINE on jacks from a seven 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 5f124836a..01305ed00 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,10 +2,19 @@ import { assertGameState, assertSnackbar } from '../../../support/helpers'; import { Card } from '../../../fixtures/cards'; import { SnackBarError } from '../../../fixtures/snackbarError'; -function assertDeckCount(count) { - cy.get('#deck .c-deck-count').invoke('text') - .invoke('trim') - .should('eq', `(${count})`); +/** + * Nines return two cards, so the player picks both targets on the board and then confirms, + * rather than the move firing on the first click. + * @param handCardSelector selector for the nine in the player's hand + * @param targetSelectors selectors for the two cards to target, in click order + */ +function playNineOneOff(handCardSelector, targetSelectors) { + cy.get(handCardSelector).click(); + cy.get('[data-move-choice=targetedOneOff]').click(); + cy.get('#player-hand-targeting').should('be.visible'); + targetSelectors.forEach((selector) => cy.get(selector).click({ force: true })); + cy.get('[data-cy=confirm-targets]').should('not.be.disabled') + .click(); } describe('Playing NINES', () => { @@ -25,7 +34,7 @@ describe('Playing NINES', () => { }); // Player plays nine - cy.get('[data-player-hand-card=9-3]').click(); // nine of spades + cy.get('[data-player-hand-card=9-3]').click(); cy.get('[data-move-choice=scuttle]').click(); cy.get('#player-hand-targeting').should('be.visible'); cy.get('[data-opponent-point-card=1-1]').click(); // ace of diamonds @@ -39,73 +48,47 @@ describe('Playing NINES', () => { p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES, Card.ACE_OF_DIAMONDS ], }); - }); // End 9 scuttle + }); - it('Plays a nine as ONE-OFF on a lower point card to put it on top of the deck', () => { + it('Plays a nine as ONE-OFF returning two point cards to their owners hand', () => { cy.loadGameFixture(0, { p0Hand: [ Card.NINE_OF_SPADES, Card.NINE_OF_HEARTS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], - p1Points: [ Card.ACE_OF_DIAMONDS ], + p1Points: [ Card.ACE_OF_DIAMONDS, Card.FOUR_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-opponent-point-card=1-1]').click(); // ace of diamonds + playNineOneOff('[data-player-hand-card=9-3]', [ + '[data-opponent-point-card=1-1]', // ace of diamonds + '[data-opponent-point-card=4-1]', // four 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 ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS, Card.ACE_OF_DIAMONDS, Card.FOUR_OF_DIAMONDS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], - deckLength: 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 + }); // End 9 one-off two point cards - it('Plays a nine as ONE-OFF on a higher point card to put it on top of the deck', () => { + it('Plays a nine as ONE-OFF on a higher point card to return it to owners hand', () => { cy.loadGameFixture(0, { p0Hand: [ Card.NINE_OF_CLUBS, Card.NINE_OF_HEARTS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [ Card.NINE_OF_SPADES ], - p1FaceCards: [], + p1FaceCards: [ Card.KING_OF_DIAMONDS ], }); - assertDeckCount(46); // Attempt illegal scuttle cy.get('[data-player-hand-card=9-0]').click(); @@ -117,11 +100,11 @@ describe('Playing NINES', () => { cy.get('[data-opponent-point-card=9-3]').click(); assertSnackbar(SnackBarError.ILLEGAL_SCUTTLE); - // Player plays nine - cy.get('[data-player-hand-card=9-2]').click(); // nine of hearts - cy.get('[data-move-choice=targetedOneOff]').click(); - cy.get('#player-hand-targeting').should('be.visible'); - cy.get('[data-opponent-point-card=9-3]').click(); // nine of spades + // A nine one-off can still return that same higher point card + playNineOneOff('[data-player-hand-card=9-2]', [ + '[data-opponent-point-card=9-3]', // nine of spades + '[data-opponent-face-card=13-1]', // king of diamonds + ]); cy.log('Successfully played nine one-off on higher point card'); // Wait for opponent to resolve @@ -132,30 +115,28 @@ describe('Playing NINES', () => { p0Hand: [ Card.NINE_OF_CLUBS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], + p1Hand: [ Card.NINE_OF_SPADES, Card.KING_OF_DIAMONDS, Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_HEARTS ], - deckLength: 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', () => { + it('Plays a nine as a ONE-OFF returning a face card and a point card', () => { cy.loadGameFixture(0, { p0Hand: [ Card.NINE_OF_SPADES, Card.NINE_OF_HEARTS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], - p1Points: [], + p1Points: [ Card.ACE_OF_DIAMONDS ], p1FaceCards: [ Card.KING_OF_DIAMONDS ], }); - 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('#player-hand-targeting').should('be.visible'); - cy.get('[data-opponent-face-card=13-1]').click(); // king of diamonds + // Mixed pair: one royal and one point card + playNineOneOff('[data-player-hand-card=9-3]', [ + '[data-opponent-face-card=13-1]', // king of diamonds + '[data-opponent-point-card=1-1]', // ace of diamonds + ]); // Wait for opponent to resolve cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); @@ -165,25 +146,22 @@ describe('Playing NINES', () => { p0Hand: [ Card.NINE_OF_HEARTS ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS, Card.KING_OF_DIAMONDS, Card.ACE_OF_DIAMONDS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], - deckLength: 47, }); - }); // End 9 on face card + }); // End 9 on face card + point card - it('Plays a 9 on a jack to steal back point card, topdecking the jack', () => { + it('Plays a 9 on a jack to steal back point card', () => { cy.loadGameFixture(0, { p0Hand: [ Card.ACE_OF_SPADES, Card.NINE_OF_CLUBS ], p0Points: [ Card.TEN_OF_SPADES ], p0FaceCards: [], p1Hand: [ Card.JACK_OF_CLUBS ], p1Points: [], - p1FaceCards: [], - topCard: Card.TWO_OF_CLUBS, + p1FaceCards: [ Card.KING_OF_DIAMONDS ], }); - assertDeckCount(48); // Player plays Ace of Spades cy.get('[data-player-hand-card=1-3]').click(); @@ -195,7 +173,7 @@ describe('Playing NINES', () => { p0FaceCards: [], p1Hand: [ Card.JACK_OF_CLUBS ], p1Points: [], - p1FaceCards: [], + p1FaceCards: [ Card.KING_OF_DIAMONDS ], }); // Opponent plays jack @@ -207,137 +185,78 @@ describe('Playing NINES', () => { p0FaceCards: [], p1Hand: [], p1Points: [ Card.ACE_OF_SPADES ], - p1FaceCards: [], + p1FaceCards: [ Card.KING_OF_DIAMONDS ], }); - // 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'); - cy.get('[data-opponent-face-card=11-0]').click(); + // Player plays NINE to destroy the jack and take back the ace. + // The jacked point card is NOT a target, so it reverts to its owner's points. + playNineOneOff('[data-player-hand-card=9-0]', [ + '[data-opponent-face-card=11-0]', // jack of clubs + '[data-opponent-face-card=13-1]', // king of diamonds + ]); // Wait for opponent to resolve 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: [], + p1Hand: [ Card.JACK_OF_CLUBS, Card.KING_OF_DIAMONDS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS ], - deckLength: 49, }); // Should no longer see jack of clubs on screen cy.get('[data-player-face-card=11-0]').should('not.exist'); - - // 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', () => { + it('Plays a 9 on a jack AND the point card it is stealing, returning both to the jack holder', () => { cy.loadGameFixture(0, { - p0Hand: [ Card.ACE_OF_SPADES, Card.NINE_OF_CLUBS, Card.JACK_OF_SPADES ], + p0Hand: [ Card.ACE_OF_SPADES, Card.NINE_OF_CLUBS ], p0Points: [ Card.TEN_OF_SPADES ], p0FaceCards: [], - p1Hand: [ Card.JACK_OF_CLUBS, Card.JACK_OF_DIAMONDS, Card.NINE_OF_HEARTS ], + p1Hand: [ Card.JACK_OF_CLUBS ], p1Points: [], p1FaceCards: [], }); - assertDeckCount(45); - // Player plays Ace of Spades + // Player plays Ace of Spades, opponent jacks it 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 ], + p0Hand: [ Card.NINE_OF_CLUBS ], p0Points: [ Card.TEN_OF_SPADES ], p0FaceCards: [], - p1Hand: [ Card.JACK_OF_DIAMONDS, Card.NINE_OF_HEARTS ], + p1Hand: [], 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(); - cy.get('#player-hand-targeting').should('be.visible'); - cy.get('[data-opponent-point-card=1-3]').click({ force: true }); - - // Opponent plays jack - cy.playJackOpponent(Card.JACK_OF_DIAMONDS, Card.ACE_OF_SPADES); - - // 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(); + // Targeting both the jack and the card it stole. Both resolve simultaneously, so both + // go to the hand of the player who controlled them -- the opponent who played the jack. + playNineOneOff('[data-player-hand-card=9-0]', [ + '[data-opponent-face-card=11-0]', // jack of clubs + '[data-opponent-point-card=1-3]', // ace of spades, the card the jack stole + ]); + cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); cy.resolveOpponent(); assertGameState(0, { p0Hand: [], - p0Points: [ Card.TEN_OF_SPADES, Card.ACE_OF_SPADES ], + p0Points: [ Card.TEN_OF_SPADES ], p0FaceCards: [], - p1Hand: [ Card.NINE_OF_HEARTS ], + p1Hand: [ Card.JACK_OF_CLUBS, Card.ACE_OF_SPADES ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS ], - deckLength: 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'); - cy.get('[data-cy=cannot-counter-resolve]').click(); - - assertGameState(0, { - p0Hand: [], - p0Points: [ Card.TEN_OF_SPADES ], - p0FaceCards: [], - p1Hand: [], - p1Points: [ Card.ACE_OF_SPADES ], - p1FaceCards: [], - scrap: [ Card.NINE_OF_CLUBS, Card.NINE_OF_HEARTS ], - deckLength: 47, - }); - }); + }); // End 9 on jack + its own host it('Plays a 9 on a point card that has been triple jacked', () => { cy.loadGameFixture(0, { @@ -346,102 +265,222 @@ describe('Playing NINES', () => { p0FaceCards: [], p1Hand: [ Card.JACK_OF_CLUBS, Card.JACK_OF_DIAMONDS, Card.NINE_OF_HEARTS ], p1Points: [], - p1FaceCards: [], - topCard: Card.TWO_OF_CLUBS, + p1FaceCards: [ Card.KING_OF_DIAMONDS ], }); - assertDeckCount(45); // Player plays Ace of Spades cy.get('[data-player-hand-card=1-3]').click(); cy.get('[data-move-choice=points]').click(); - // Opponent plays jack + // Opponent jacks it, player jacks it back, opponent jacks it again cy.playJackOpponent(Card.JACK_OF_CLUBS, Card.ACE_OF_SPADES); - - // player plays jack cy.get('[data-player-hand-card=11-3]').click(); cy.get('[data-move-choice=jack]').click(); cy.get('#player-hand-targeting').should('be.visible'); cy.get('[data-opponent-point-card=1-3]').click({ force: true }); - - // Opponent plays jack cy.playJackOpponent(Card.JACK_OF_DIAMONDS, Card.ACE_OF_SPADES); - // 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'); - cy.get('[data-opponent-point-card=1-3]').click({ force: true }); + // Nine the triple-jacked point card itself, plus the king. All three jacks are + // scrapped since none of them was targeted. + playNineOneOff('[data-player-hand-card=9-0]', [ + '[data-opponent-point-card=1-3]', // ace of spades, triple jacked + '[data-opponent-face-card=13-1]', // king of diamonds + ]); cy.resolveOpponent(); - // The ace is topdecked and all three jacks are scrapped assertGameState(0, { p0Hand: [], p0Points: [ Card.TEN_OF_SPADES ], p0FaceCards: [], - p1Hand: [ Card.NINE_OF_HEARTS ], + p1Hand: [ Card.NINE_OF_HEARTS, Card.ACE_OF_SPADES, Card.KING_OF_DIAMONDS ], p1Points: [], p1FaceCards: [], - scrap: [ Card.JACK_OF_DIAMONDS, Card.JACK_OF_CLUBS, Card.JACK_OF_SPADES, Card.NINE_OF_CLUBS ], - deckLength: 46, + scrap: [ + Card.JACK_OF_DIAMONDS, + Card.JACK_OF_CLUBS, + Card.JACK_OF_SPADES, + Card.NINE_OF_CLUBS, + ], + }); + }); // End 9 on triple jacked point card + + it('Plays a 9 on a point card and a jack attached to a different point card', () => { + cy.loadGameFixture(0, { + p0Hand: [ Card.ACE_OF_SPADES, Card.NINE_OF_CLUBS ], + p0Points: [ Card.TEN_OF_SPADES ], + p0FaceCards: [], + p1Hand: [ Card.JACK_OF_CLUBS ], + p1Points: [ Card.FOUR_OF_DIAMONDS ], + p1FaceCards: [], }); - // Opponent draws the ace back, and the player draws the card beneath it - cy.drawCardOpponent(); - cy.get('#deck').click(); - assertDeckCount(44); + // Player plays Ace of Spades, opponent jacks it + cy.get('[data-player-hand-card=1-3]').click(); + cy.get('[data-move-choice=points]').click(); + cy.playJackOpponent(Card.JACK_OF_CLUBS, Card.ACE_OF_SPADES); - cy.playPointsOpponent(Card.ACE_OF_SPADES); + // Target the jack (whose host reverts to its owner) and an unrelated point card + playNineOneOff('[data-player-hand-card=9-0]', [ + '[data-opponent-face-card=11-0]', // jack of clubs, stealing the ace + '[data-opponent-point-card=4-1]', // four of diamonds + ]); + + cy.resolveOpponent(); assertGameState(0, { - p0Hand: [ Card.TWO_OF_CLUBS ], - p0Points: [ Card.TEN_OF_SPADES ], + p0Hand: [], + p0Points: [ Card.TEN_OF_SPADES, Card.ACE_OF_SPADES ], p0FaceCards: [], - p1Hand: [ Card.NINE_OF_HEARTS ], - p1Points: [ Card.ACE_OF_SPADES ], + p1Hand: [ Card.JACK_OF_CLUBS, Card.FOUR_OF_DIAMONDS ], + p1Points: [], p1FaceCards: [], - scrap: [ Card.JACK_OF_DIAMONDS, Card.JACK_OF_CLUBS, Card.JACK_OF_SPADES, Card.NINE_OF_CLUBS ], + scrap: [ Card.NINE_OF_CLUBS ], }); - }); + }); // End 9 on point card + jack on a different host - it('Cancels playing a nine one off', () => { - cy.loadGameFixture(0, { - p0Hand: [ Card.NINE_OF_SPADES, Card.NINE_OF_HEARTS ], + it('Cancels playing a nine one off after selecting no targets and after selecting both', () => { + const initialState = { + p0Hand: [ Card.NINE_OF_HEARTS, Card.NINE_OF_SPADES ], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], - p1Points: [ Card.ACE_OF_DIAMONDS ], + p1Points: [ Card.ACE_OF_DIAMONDS, Card.FOUR_OF_DIAMONDS ], p1FaceCards: [], - }); + scrap: [], + }; + cy.loadGameFixture(0, initialState); - // Player plays nine - cy.get('[data-player-hand-card=9-3]').click(); // nine of spades + // Cancel before choosing any target + 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-cy=cancel-target]').click(); + cy.get('#player-hand-targeting').should('not.exist'); + assertGameState(0, initialState); - // Cancels + // Cancel after choosing one target + 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({ force: true }); cy.get('[data-cy=cancel-target]').click(); cy.get('#player-hand-targeting').should('not.exist'); + assertGameState(0, initialState); + // Cancel after choosing both targets, without confirming + 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({ force: true }); + cy.get('[data-opponent-point-card=4-1]').click({ force: true }); + cy.get('[data-cy=confirm-targets]').should('not.be.disabled'); + cy.get('[data-cy=cancel-target]').click(); + cy.get('#player-hand-targeting').should('not.exist'); + assertGameState(0, initialState); + }); + + it('Requires two targets before the nine one-off can be confirmed, and allows deselecting', () => { + cy.loadGameFixture(0, { + p0Hand: [ Card.NINE_OF_SPADES ], + p0Points: [ Card.TEN_OF_HEARTS ], + p0FaceCards: [], + p1Hand: [], + p1Points: [ Card.ACE_OF_DIAMONDS, Card.FOUR_OF_DIAMONDS, Card.FIVE_OF_DIAMONDS ], + p1FaceCards: [], + }); + + cy.get('[data-player-hand-card=9-3]').click(); + cy.get('[data-move-choice=targetedOneOff]').click(); + cy.get('#player-hand-targeting').should('be.visible'); + + // Nothing chosen yet + cy.get('[data-cy=confirm-targets]').should('be.disabled'); + + // One chosen is still not enough + cy.get('[data-opponent-point-card=1-1]').click({ force: true }); + cy.get('[data-cy=confirm-targets]').should('be.disabled'); + + // Clicking a chosen target again deselects it + cy.get('[data-opponent-point-card=1-1]').click({ force: true }); + cy.get('[data-cy=confirm-targets]').should('be.disabled'); + + // Choose a different pair and send it + cy.get('[data-opponent-point-card=4-1]').click({ force: true }); + cy.get('[data-opponent-point-card=5-1]').click({ force: true }); + cy.get('[data-cy=confirm-targets]').should('not.be.disabled') + .click(); + + cy.resolveOpponent(); + + // The deselected ace stayed on the board; the confirmed pair bounced assertGameState(0, { - p0Hand: [ Card.NINE_OF_HEARTS, Card.NINE_OF_SPADES ], + p0Hand: [], p0Points: [ Card.TEN_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], + p1Hand: [ Card.FOUR_OF_DIAMONDS, Card.FIVE_OF_DIAMONDS ], p1Points: [ Card.ACE_OF_DIAMONDS ], p1FaceCards: [], + scrap: [ Card.NINE_OF_SPADES ], + }); + }); + + it('Cannot play a nine one-off while the opponent has a queen', () => { + cy.loadGameFixture(0, { + p0Hand: [ Card.NINE_OF_SPADES ], + p0Points: [ Card.TEN_OF_HEARTS ], + p0FaceCards: [], + p1Hand: [], + p1Points: [ Card.ACE_OF_DIAMONDS, Card.FOUR_OF_DIAMONDS ], + p1FaceCards: [ Card.QUEEN_OF_DIAMONDS ], + }); + + cy.get('[data-player-hand-card=9-3]').click(); + + // The move choice explains why a single queen is enough to stop a nine + cy.get('[data-move-choice=targetedOneOff]') + .should('have.class', 'v-card--disabled') + .should('contain', 'You can\'t play a Nine while your opponent has a Queen') + .click({ force: true }); + + // Forcing the request through anyway is rejected by the server + cy.get('#player-hand-targeting').should('be.visible'); + cy.get('[data-opponent-point-card=1-1]').click({ force: true }); + cy.get('[data-opponent-point-card=4-1]').click({ force: true }); + cy.get('[data-cy=confirm-targets]').click(); + assertSnackbar(SnackBarError.ONE_OFF.NINE.BLOCKED_BY_ANY_QUEEN); + + assertGameState(0, { + p0Hand: [ Card.NINE_OF_SPADES ], + p0Points: [ Card.TEN_OF_HEARTS ], + p0FaceCards: [], + p1Hand: [], + p1Points: [ Card.ACE_OF_DIAMONDS, Card.FOUR_OF_DIAMONDS ], + p1FaceCards: [ Card.QUEEN_OF_DIAMONDS ], scrap: [], - deckLength: 46, }); }); - it('Plays a nine as a ONE-OFF; the topdecked card is drawn back and replayed immediately', () => { + it('Cannot play a nine one-off with fewer than two legal targets', () => { + cy.loadGameFixture(0, { + p0Hand: [ Card.NINE_OF_SPADES ], + p0Points: [ Card.TEN_OF_HEARTS ], + p0FaceCards: [], + p1Hand: [], + p1Points: [ Card.ACE_OF_DIAMONDS ], + p1FaceCards: [], + }); + + cy.get('[data-player-hand-card=9-3]').click(); + cy.get('[data-move-choice=targetedOneOff]') + .should('have.class', 'v-card--disabled') + .should('contain', 'A Nine needs two cards to target'); + }); + + it('Plays a nine as a ONE-OFF, make sure that the bounced cards are playable later', () => { /* - 1). P0 plays a 9, topdecking an in-play Queen + 1). P0 plays a 9, targeting both of P1's in-play Kings 2). P1 plays a 6's one-off, removing some cards - 3). P0 plays a Jack, targeting an in-play 10 - 4). P1 draws their topdecked Queen and replays it with no delay + 3). P0 plays a Jack, targeting an in-play Four + 4). P1 can replay one of their bounced Kings right away */ // Initial state @@ -449,19 +488,18 @@ describe('Playing NINES', () => { p0Hand: [ Card.NINE_OF_SPADES, Card.JACK_OF_SPADES ], p0Points: [], p0FaceCards: [ Card.KING_OF_HEARTS ], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], - p1Points: [ Card.TEN_OF_HEARTS ], - p1FaceCards: [ Card.QUEEN_OF_DIAMONDS ], - topCard: Card.TWO_OF_CLUBS, + p1Hand: [ Card.SIX_OF_HEARTS ], + // Two kings drop the opponent's goal to 10, so their points must stay below it + p1Points: [ Card.FOUR_OF_HEARTS ], + p1FaceCards: [ Card.KING_OF_DIAMONDS, Card.KING_OF_CLUBS ], }); - assertDeckCount(45); // STEP 1 - 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'); - cy.get('[data-opponent-face-card=12-1]').click(); // queen of diamonds + cy.log('STEP 1- P0 plays nine, targeting both Kings in play'); + playNineOneOff('[data-player-hand-card=9-3]', [ + '[data-opponent-face-card=13-1]', // king of diamonds + '[data-opponent-face-card=13-0]', // king of clubs + ]); // Wait for opponent to resolve cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); @@ -471,11 +509,10 @@ describe('Playing NINES', () => { p0Hand: [ Card.JACK_OF_SPADES ], p0Points: [], p0FaceCards: [ Card.KING_OF_HEARTS ], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], - p1Points: [ Card.TEN_OF_HEARTS ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.KING_OF_DIAMONDS, Card.KING_OF_CLUBS ], + p1Points: [ Card.FOUR_OF_HEARTS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], - deckLength: 46, }); // STEP 2 @@ -490,54 +527,50 @@ describe('Playing NINES', () => { p0Hand: [ Card.JACK_OF_SPADES ], p0Points: [], p0FaceCards: [], - p1Hand: [ Card.QUEEN_OF_HEARTS ], - p1Points: [ Card.TEN_OF_HEARTS ], + p1Hand: [ Card.KING_OF_DIAMONDS, Card.KING_OF_CLUBS ], + p1Points: [ Card.FOUR_OF_HEARTS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES, Card.KING_OF_HEARTS, Card.SIX_OF_HEARTS ], }); // STEP 3 - cy.log('STEP 3- P0 plays a Jack, targeting the Ten'); + cy.log('STEP 3- P0 plays a Jack, targeting the Four'); cy.get('[data-player-hand-card=11-3]').click(); cy.get('[data-move-choice=jack]').click(); - cy.get('[data-opponent-point-card=10-2]').click(); + cy.get('[data-opponent-point-card=4-2]').click(); assertGameState(0, { p0Hand: [], - p0Points: [ Card.TEN_OF_HEARTS ], + p0Points: [ Card.FOUR_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.QUEEN_OF_HEARTS ], + p1Hand: [ Card.KING_OF_DIAMONDS, Card.KING_OF_CLUBS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES, Card.KING_OF_HEARTS, Card.SIX_OF_HEARTS ], }); // STEP 4 - 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); + cy.log('STEP 4- P1 plays one of their previously bounced Kings'); + cy.playFaceCardOpponent(Card.KING_OF_DIAMONDS); assertGameState(0, { - p0Hand: [ Card.TWO_OF_CLUBS ], - p0Points: [ Card.TEN_OF_HEARTS ], + p0Hand: [], + p0Points: [ Card.FOUR_OF_HEARTS ], p0FaceCards: [], - p1Hand: [ Card.QUEEN_OF_HEARTS ], + p1Hand: [ Card.KING_OF_CLUBS ], p1Points: [], - p1FaceCards: [ Card.QUEEN_OF_DIAMONDS ], + p1FaceCards: [ Card.KING_OF_DIAMONDS ], scrap: [ Card.NINE_OF_SPADES, Card.KING_OF_HEARTS, Card.SIX_OF_HEARTS ], }); }); - it('Plays a nine as a ONE-OFF; the topdecked card returns even when a later one-off is countered', () => { + it('Plays a nine as a ONE-OFF, bounced cards are playable even if there is countering', () => { /* - 1). P0 plays 9 one-off, topdecking P1's Queen + 1). P0 plays 9 one-off, targeting both of P1's Kings 2). P1 plays 6's one-off 3). P0 counters and it fizzles - 4). P0 plays their Jack, targeting P1's 10 - 5). P1 draws their Queen back and plays it again + 4). P0 plays their Jack, targeting P1's Four + 5). P1 plays a bounced King again right away */ // Initial state @@ -545,19 +578,18 @@ describe('Playing NINES', () => { p0Hand: [ Card.TWO_OF_SPADES, Card.NINE_OF_SPADES, Card.JACK_OF_SPADES ], p0Points: [], p0FaceCards: [ Card.KING_OF_HEARTS ], - p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], - p1Points: [ Card.TEN_OF_HEARTS ], - p1FaceCards: [ Card.QUEEN_OF_DIAMONDS ], - topCard: Card.TWO_OF_CLUBS, + p1Hand: [ Card.SIX_OF_HEARTS ], + // Two kings drop the opponent's goal to 10, so their points must stay below it + p1Points: [ Card.FOUR_OF_HEARTS ], + p1FaceCards: [ Card.KING_OF_DIAMONDS, Card.KING_OF_CLUBS ], }); - assertDeckCount(44); // STEP 1 - 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'); - cy.get('[data-opponent-face-card=12-1]').click(); // queen of diamonds + cy.log('STEP 1- P0 plays nine, targeting both Kings in play'); + playNineOneOff('[data-player-hand-card=9-3]', [ + '[data-opponent-face-card=13-1]', // king of diamonds + '[data-opponent-face-card=13-0]', // king of clubs + ]); // Wait for opponent to resolve cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); @@ -567,11 +599,10 @@ 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 ], - p1Points: [ Card.TEN_OF_HEARTS ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.KING_OF_DIAMONDS, Card.KING_OF_CLUBS ], + p1Points: [ Card.FOUR_OF_HEARTS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], - deckLength: 45, }); // STEP 2 @@ -580,7 +611,6 @@ describe('Playing NINES', () => { // STEP 3 cy.log('STEP 3- P0 counters'); - cy.get('#counter-dialog').should('be.visible') .get('[data-cy=counter]') .click(); @@ -597,34 +627,30 @@ describe('Playing NINES', () => { p0Hand: [ Card.JACK_OF_SPADES ], p0Points: [], p0FaceCards: [ Card.KING_OF_HEARTS ], - p1Hand: [ Card.QUEEN_OF_HEARTS ], - p1Points: [ Card.TEN_OF_HEARTS ], + p1Hand: [ Card.KING_OF_DIAMONDS, Card.KING_OF_CLUBS ], + p1Points: [ Card.FOUR_OF_HEARTS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES, Card.SIX_OF_HEARTS, Card.TWO_OF_SPADES ], }); - // Step 4 - cy.log('STEP 4- P0 Jacks P1\'s 10'); - cy.get('[data-player-hand-card=11-3]').click(); // Jack of spades + // STEP 4 + cy.log('STEP 4- P0 Jacks P1\'s Four'); + cy.get('[data-player-hand-card=11-3]').click(); cy.get('[data-move-choice=jack]').click(); cy.get('#player-hand-targeting').should('be.visible'); - cy.get('[data-opponent-point-card=10-2]').click(); // Ten of hearts + cy.get('[data-opponent-point-card=4-2]').click(); // 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); + cy.log('STEP 5- P1 plays a previously bounced King'); + cy.playFaceCardOpponent(Card.KING_OF_DIAMONDS); assertGameState(0, { - p0Hand: [ Card.TWO_OF_CLUBS ], - p0Points: [ Card.TEN_OF_HEARTS ], + p0Hand: [], + p0Points: [ Card.FOUR_OF_HEARTS ], p0FaceCards: [ Card.KING_OF_HEARTS ], - p1Hand: [ Card.QUEEN_OF_HEARTS ], + p1Hand: [ Card.KING_OF_CLUBS ], p1Points: [], - p1FaceCards: [ Card.QUEEN_OF_DIAMONDS ], + p1FaceCards: [ Card.KING_OF_DIAMONDS ], scrap: [ Card.NINE_OF_SPADES, Card.SIX_OF_HEARTS, Card.TWO_OF_SPADES ], }); }); @@ -635,86 +661,16 @@ describe('Playing NINES', () => { cy.setupGameAsP1(); }); - 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 ], - p0FaceCards: [], - p1Hand: [], - p1Points: [ Card.SEVEN_OF_CLUBS ], - p1FaceCards: [], - topCard: Card.TEN_OF_CLUBS, - }); - assertDeckCount(49); - - // opponent plays nine to topdeck the player's seven - cy.playTargetedOneOffOpponent(Card.NINE_OF_CLUBS, Card.SEVEN_OF_CLUBS, 'point'); - - // Player resolves - cy.get('#cannot-counter-dialog').should('be.visible') - .get('[data-cy=cannot-counter-resolve]') - .click(); - - // The seven is back on top of the deck - 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 ], - p0FaceCards: [], - 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'); - - cy.drawCardOpponent(); - assertDeckCount(48); - - // Player scuttles with the card that was topdecked - cy.get('[data-player-hand-card=7-0]').click(); - cy.get('[data-move-choice=scuttle]').click(); - cy.get('#player-hand-targeting').should('be.visible'); - cy.get('[data-opponent-point-card=3-0]').click(); - - 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', () => { + it('Opponent plays a NINE on a jack to steal back point card, and the jack is replayable at once', () => { cy.loadGameFixture(1, { p0Hand: [ Card.ACE_OF_SPADES, Card.NINE_OF_CLUBS, Card.ACE_OF_DIAMONDS ], p0Points: [ Card.TEN_OF_SPADES ], p0FaceCards: [], p1Hand: [ Card.JACK_OF_CLUBS, Card.TEN_OF_DIAMONDS ], p1Points: [], - p1FaceCards: [], + p1FaceCards: [ Card.KING_OF_DIAMONDS ], topCard: Card.TEN_OF_CLUBS, }); - assertDeckCount(46); // opponent plays Ace of Spades cy.playPointsOpponent(Card.ACE_OF_SPADES); @@ -731,87 +687,90 @@ describe('Playing NINES', () => { p0FaceCards: [], p1Hand: [ Card.TEN_OF_DIAMONDS ], p1Points: [ Card.ACE_OF_SPADES ], - p1FaceCards: [], + p1FaceCards: [ Card.KING_OF_DIAMONDS ], }); - cy.playTargetedOneOffOpponent(Card.NINE_OF_CLUBS, Card.JACK_OF_CLUBS, 'jack'); + // Opponent nines the jack and the king. The jacked ace is not a target, so it + // reverts to its owner's points. + cy.playTargetedOneOffOpponent( + Card.NINE_OF_CLUBS, + Card.JACK_OF_CLUBS, + 'jack', + Card.KING_OF_DIAMONDS, + 'faceCard', + ); // Player resolves cy.get('#cannot-counter-dialog').should('be.visible') .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.TEN_OF_DIAMONDS ], + p1Hand: [ Card.JACK_OF_CLUBS, Card.TEN_OF_DIAMONDS, Card.KING_OF_DIAMONDS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS ], - deckLength: 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: [], - 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 ], - }); + // Nines no longer freeze what they return, so the jack is playable immediately cy.get('[data-player-hand-card=11-0]').should('not.have.class', 'frozen'); - - cy.drawCardOpponent(); - - // Player replays the jack cy.get('[data-player-hand-card=11-0]').click(); - cy.get('[data-move-choice=jack]').click(); + cy.get('[data-player-overlay-card=11-0]').should('not.have.class', 'frozen'); + cy.get('[data-move-choice=jack]').should('not.have.class', 'v-card--disabled') + .click(); cy.get('#player-hand-targeting').should('be.visible'); - cy.get('[data-opponent-point-card=1-1]').click(); + cy.get('[data-opponent-point-card=1-3]').click(); + cy.log('Correctly allowed player to replay the returned card immediately'); assertGameState(1, { - p0Hand: [ Card.TEN_OF_CLUBS ], - p0Points: [ Card.TEN_OF_SPADES, Card.ACE_OF_SPADES ], + p0Hand: [ Card.ACE_OF_DIAMONDS ], + p0Points: [ Card.TEN_OF_SPADES ], p0FaceCards: [], - p1Hand: [], - p1Points: [ Card.ACE_OF_DIAMONDS, Card.TEN_OF_DIAMONDS ], + p1Hand: [ Card.TEN_OF_DIAMONDS, Card.KING_OF_DIAMONDS ], + p1Points: [ Card.ACE_OF_SPADES ], p1FaceCards: [], scrap: [ Card.NINE_OF_CLUBS ], }); }); // End 9 on jack - it('Keeps the topdecked card on the deck through a rejected stalemate and a reload', () => { + it('Keeps returned cards playable after requesting a stalemate and reloading', () => { cy.loadGameFixture(1, { p0Hand: [ Card.NINE_OF_CLUBS ], p0Points: [ Card.THREE_OF_CLUBS ], p0FaceCards: [], p1Hand: [], - p1Points: [ Card.SEVEN_OF_CLUBS ], + p1Points: [ Card.SEVEN_OF_CLUBS, Card.FOUR_OF_CLUBS ], p1FaceCards: [], topCard: Card.TEN_OF_CLUBS, }); - // opponent plays nine to topdeck the player's seven - cy.playTargetedOneOffOpponent(Card.NINE_OF_CLUBS, Card.SEVEN_OF_CLUBS, 'point'); + // opponent plays nine to return both point cards to player's hand + cy.playTargetedOneOffOpponent( + Card.NINE_OF_CLUBS, + Card.SEVEN_OF_CLUBS, + 'point', + Card.FOUR_OF_CLUBS, + 'point', + ); // Player resolves cy.get('#cannot-counter-dialog').should('be.visible') .get('[data-cy=cannot-counter-resolve]') .click(); - assertDeckCount(50); + assertGameState(1, { + p0Hand: [], + p0Points: [ Card.THREE_OF_CLUBS ], + p0FaceCards: [], + p1Hand: [ Card.SEVEN_OF_CLUBS, Card.FOUR_OF_CLUBS ], + p1Points: [], + p1FaceCards: [], + }); + + cy.get('[data-player-hand-card=7-0]').should('not.have.class', 'frozen'); // Player requests stalemate; opponent rejects cy.get('#game-menu-activator').click(); @@ -828,39 +787,35 @@ describe('Playing NINES', () => { cy.rejectStalemateOpponent(); cy.get('#waiting-for-opponent-stalemate-scrim').should('not.exist'); - cy.get('[data-player-hand-card]').should('not.exist'); - assertDeckCount(50); - - // Still on the deck after reload + // Returned cards are still unfrozen, and stay that way through a reload + cy.get('[data-player-hand-card=7-0]').should('not.have.class', 'frozen'); cy.reload(); - cy.get('[data-player-hand-card]').should('not.exist'); - assertDeckCount(50); + cy.get('[data-player-hand-card=7-0]').should('not.have.class', 'frozen'); assertGameState(1, { p0Hand: [], p0Points: [ Card.THREE_OF_CLUBS ], p0FaceCards: [], - p1Hand: [], + p1Hand: [ Card.SEVEN_OF_CLUBS, Card.FOUR_OF_CLUBS ], p1Points: [], p1FaceCards: [], - scrap: [ Card.NINE_OF_CLUBS ], }); - // The seven is still the very next card off the deck - cy.get('#deck').click(); + // And one of them can actually be played + cy.get('[data-player-hand-card=7-0]').click(); + cy.get('[data-move-choice=points]').click(); assertGameState(1, { p0Hand: [], p0Points: [ Card.THREE_OF_CLUBS ], p0FaceCards: [], - p1Hand: [ Card.SEVEN_OF_CLUBS ], - p1Points: [], + p1Hand: [ Card.FOUR_OF_CLUBS ], + p1Points: [ Card.SEVEN_OF_CLUBS ], p1FaceCards: [], - scrap: [ Card.NINE_OF_CLUBS ], }); }); - it('Does not trigger discard-to-hand-limit for the player at the hand limit', () => { + it('Nine returns two cards to player hand past the hand limit', () => { cy.loadGameFixture(1, { p0Hand: [ Card.NINE_OF_SPADES ], p0Points: [], @@ -875,18 +830,24 @@ describe('Playing NINES', () => { Card.EIGHT_OF_CLUBS, Card.NINE_OF_CLUBS, ], - p1Points: [ Card.TEN_OF_CLUBS ], + p1Points: [ Card.TEN_OF_CLUBS, Card.TEN_OF_DIAMONDS ], 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'); + // Opponent (P0) plays nine targeting both of the player's (P1's) point cards. + // The hand limit is only enforced at the end of the active player's turn, and the + // active player here is P0, so P1 simply holds ten cards. + cy.playTargetedOneOffOpponent( + Card.NINE_OF_SPADES, + Card.TEN_OF_CLUBS, + 'point', + Card.TEN_OF_DIAMONDS, + 'point', + ); cy.get('#cannot-counter-dialog').should('be.visible') .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: [], @@ -900,173 +861,114 @@ describe('Playing NINES', () => { Card.SEVEN_OF_CLUBS, Card.EIGHT_OF_CLUBS, Card.NINE_OF_CLUBS, + Card.TEN_OF_CLUBS, + Card.TEN_OF_DIAMONDS, ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], - deckLength: 43, }); - cy.get('#discard-to-hand-limit-dialog').should('not.exist'); }); }); // End Opponent playing NINES describe - describe('Nine does not trigger discard-to-hand-limit', () => { + describe('Playing a NINE from a seven', () => { beforeEach(() => { cy.setupGameAsP0(); }); - it('Does not trigger discard-to-hand-limit for the opponent at the hand limit', () => { + it('Plays a two-target nine off the top of the deck via a seven', () => { cy.loadGameFixture(0, { - p0Hand: [ Card.NINE_OF_SPADES ], + p0Hand: [ Card.SEVEN_OF_CLUBS ], p0Points: [], p0FaceCards: [], - p1Hand: [ - Card.ACE_OF_HEARTS, - Card.TWO_OF_HEARTS, - Card.THREE_OF_HEARTS, - Card.FOUR_OF_HEARTS, - Card.FIVE_OF_HEARTS, - Card.SIX_OF_HEARTS, - Card.SEVEN_OF_HEARTS, - Card.EIGHT_OF_HEARTS, - ], - p1Points: [ Card.TEN_OF_DIAMONDS ], - p1FaceCards: [], + p1Hand: [], + p1Points: [ Card.ACE_OF_DIAMONDS ], + p1FaceCards: [ Card.KING_OF_HEARTS ], + topCard: Card.NINE_OF_SPADES, + secondCard: Card.JACK_OF_CLUBS, }); - assertDeckCount(42); - // Player plays nine as targeted one-off against opponent's point card - cy.get('[data-player-hand-card=9-3]').click(); + cy.playOneOffAndResolveAsPlayer(Card.SEVEN_OF_CLUBS); + + // Play the nine off the top of the deck, targeting both of the opponent's cards + cy.get('[data-top-card=9-3]').should('exist') + .and('be.visible') + .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.get('[data-opponent-face-card=13-2]').click({ force: true }); // king of hearts + cy.get('[data-opponent-point-card=1-1]').click({ force: true }); // ace of diamonds + cy.get('[data-cy=confirm-targets]').should('not.be.disabled') + .click(); + + cy.get('#waiting-for-opponent-counter-scrim').should('be.visible'); cy.resolveOpponent(); + cy.get('#waiting-for-opponent-counter-scrim').should('not.exist'); assertGameState(0, { p0Hand: [], p0Points: [], p0FaceCards: [], - p1Hand: [ - Card.ACE_OF_HEARTS, - Card.TWO_OF_HEARTS, - Card.THREE_OF_HEARTS, - Card.FOUR_OF_HEARTS, - Card.FIVE_OF_HEARTS, - Card.SIX_OF_HEARTS, - Card.SEVEN_OF_HEARTS, - Card.EIGHT_OF_HEARTS, - ], + p1Hand: [ Card.KING_OF_HEARTS, Card.ACE_OF_DIAMONDS ], p1Points: [], p1FaceCards: [], - scrap: [ Card.NINE_OF_SPADES ], - deckLength: 43, + scrap: [ Card.SEVEN_OF_CLUBS, Card.NINE_OF_SPADES ], }); - cy.get('#discard-to-hand-limit-dialog').should('not.exist'); }); - }); // End Nine does not trigger discard-to-hand-limit + }); // End playing a NINE from a seven - describe('Playing NINES with an empty deck', () => { + describe('Nine triggers discard-to-hand-limit', () => { beforeEach(() => { cy.setupGameAsP0(); }); - it('Puts a card back on an empty deck, replacing the pass with a draw', () => { + it('Nine returns two cards to opponent hand at hand limit', () => { cy.loadGameFixture(0, { p0Hand: [ Card.NINE_OF_SPADES ], p0Points: [], p0FaceCards: [], - p1Hand: [], - p1Points: [ Card.TEN_OF_DIAMONDS ], + p1Hand: [ + Card.ACE_OF_HEARTS, + Card.TWO_OF_HEARTS, + Card.THREE_OF_HEARTS, + Card.FOUR_OF_HEARTS, + Card.FIVE_OF_HEARTS, + Card.SIX_OF_HEARTS, + Card.SEVEN_OF_HEARTS, + Card.EIGHT_OF_HEARTS, + ], + p1Points: [ Card.TEN_OF_DIAMONDS, Card.NINE_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(); + // Player plays nine as targeted one-off against both of the opponent's point cards + playNineOneOff('[data-player-hand-card=9-3]', [ + '[data-opponent-point-card=10-1]', // ten of diamonds + '[data-opponent-point-card=9-1]', // nine of diamonds + ]); cy.resolveOpponent(); - // 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 - - 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: [], + p1Hand: [ + Card.ACE_OF_HEARTS, + Card.TWO_OF_HEARTS, + Card.THREE_OF_HEARTS, + Card.FOUR_OF_HEARTS, + Card.FIVE_OF_HEARTS, + Card.SIX_OF_HEARTS, + Card.SEVEN_OF_HEARTS, + Card.EIGHT_OF_HEARTS, + Card.TEN_OF_DIAMONDS, + Card.NINE_OF_DIAMONDS, + ], 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 + }); // End Nine triggers discard-to-hand-limit }); 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 754229459..60b852eec 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 @@ -14,29 +14,41 @@ describe('Clean-up of One-Off Targets', () => { p0FaceCards: [], // player is p1 p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS ], - p1Points: [ Card.ACE_OF_DIAMONDS ], + p1Points: [ Card.ACE_OF_DIAMONDS, Card.FOUR_OF_DIAMONDS ], p1FaceCards: [], }); - // Opponent plays NINE - cy.playTargetedOneOffOpponent(Card.NINE_OF_SPADES, Card.ACE_OF_DIAMONDS, 'point'); + // Opponent plays NINE, which needs two targets + cy.playTargetedOneOffOpponent( + Card.NINE_OF_SPADES, + Card.ACE_OF_DIAMONDS, + 'point', + Card.FOUR_OF_DIAMONDS, + 'point', + ); // Player resolves cy.get('#cannot-counter-dialog').should('be.visible') .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 ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.QUEEN_OF_HEARTS, Card.ACE_OF_DIAMONDS, Card.FOUR_OF_DIAMONDS ], p1Points: [], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], }); + // Both target slots are cleared once the one-off resolves + cy.window().its('cuttle.gameStore') + .then((store) => { + expect(store.oneOffTarget).to.eq(null); + expect(store.oneOffTargetTwo).to.eq(null); + }); + // Player plays another point cy.get('[data-player-hand-card=6-2]').click(); cy.get('[data-move-choice=points]').click(); @@ -61,11 +73,18 @@ describe('Clean-up of One-Off Targets', () => { // player is p1 p1Hand: [ Card.SIX_OF_HEARTS ], p1Points: [ Card.ACE_OF_DIAMONDS ], - p1FaceCards: [ Card.QUEEN_OF_HEARTS ], + p1FaceCards: [ Card.KING_OF_HEARTS, Card.KING_OF_CLUBS ], }); - // Opponent plays NINE - cy.playTargetedOneOffOpponent(Card.NINE_OF_SPADES, Card.QUEEN_OF_HEARTS, 'faceCard'); + // Opponent plays NINE targeting both royals. A queen would block a nine outright, + // so this uses kings. + cy.playTargetedOneOffOpponent( + Card.NINE_OF_SPADES, + Card.KING_OF_HEARTS, + 'faceCard', + Card.KING_OF_CLUBS, + 'faceCard', + ); // Player resolves cy.get('#cannot-counter-dialog') @@ -78,12 +97,19 @@ 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 ], + p1Hand: [ Card.SIX_OF_HEARTS, Card.KING_OF_HEARTS, Card.KING_OF_CLUBS ], p1Points: [ Card.ACE_OF_DIAMONDS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES ], }); + // Both target slots are cleared once the one-off resolves + cy.window().its('cuttle.gameStore') + .then((store) => { + expect(store.oneOffTarget).to.eq(null); + expect(store.oneOffTargetTwo).to.eq(null); + }); + // Player plays another point cy.get('[data-player-hand-card=6-2]').click(); cy.get('[data-move-choice=points]').click(); @@ -173,12 +199,18 @@ describe('Clean-up of One-Off Targets', () => { p0FaceCards: [], // player is p1 p1Hand: [ Card.SIX_OF_HEARTS, Card.TWO_OF_CLUBS ], - p1Points: [ Card.ACE_OF_DIAMONDS ], + p1Points: [ Card.ACE_OF_DIAMONDS, Card.FOUR_OF_DIAMONDS ], p1FaceCards: [], }); - // Opponent plays NINE - cy.playTargetedOneOffOpponent(Card.NINE_OF_SPADES, Card.ACE_OF_DIAMONDS, 'point'); + // Opponent plays NINE, which needs two targets + cy.playTargetedOneOffOpponent( + Card.NINE_OF_SPADES, + Card.ACE_OF_DIAMONDS, + 'point', + Card.FOUR_OF_DIAMONDS, + 'point', + ); // Player counters cy.get('#counter-dialog').should('be.visible') @@ -197,11 +229,19 @@ describe('Clean-up of One-Off Targets', () => { p0FaceCards: [], // player is p1 p1Hand: [ Card.SIX_OF_HEARTS ], - p1Points: [ Card.ACE_OF_DIAMONDS ], + p1Points: [ Card.ACE_OF_DIAMONDS, Card.FOUR_OF_DIAMONDS ], p1FaceCards: [], scrap: [ Card.NINE_OF_SPADES, Card.TWO_OF_CLUBS ], }); + // Both target slots are cleared once the one-off is countered + cy.window() + .its('cuttle.gameStore') + .then((store) => { + expect(store.oneOffTarget).to.eq(null); + expect(store.oneOffTargetTwo).to.eq(null); + }); + // Player plays another point cy.get('[data-player-hand-card=6-2]').click(); cy.get('[data-move-choice=points]').click(); diff --git a/tests/e2e/support/commands.js b/tests/e2e/support/commands.js index 9fe4b5b56..b9531df0e 100644 --- a/tests/e2e/support/commands.js +++ b/tests/e2e/support/commands.js @@ -408,8 +408,10 @@ Cypress.Commands.add('playOneOffOpponent', (card, gameId = null) => { * @param card {suit: number, rank: number} * @param target {suit: number, rank: number} * @param targetType string 'faceCard' | 'point' | 'jack' + * @param targetTwo {suit: number, rank: number} OPTIONAL second target, required for nines + * @param targetTwoType string OPTIONAL 'faceCard' | 'point' | 'jack' -- the second target's location */ -Cypress.Commands.add('playTargetedOneOffOpponent', (card, target, targetType) => { +Cypress.Commands.add('playTargetedOneOffOpponent', (card, target, targetType, targetTwo, targetTwoType) => { if (!hasValidSuitAndRank(card)) { throw new Error(`Cannot play targeted one-off with invalid card ${card}`); } @@ -418,12 +420,18 @@ Cypress.Commands.add('playTargetedOneOffOpponent', (card, target, targetType) => throw new Error(`Cannot play targeted one-off with invalid target ${target}`); } + if (targetTwo && !hasValidSuitAndRank(targetTwo)) { + throw new Error(`Cannot play targeted one-off with invalid second target ${targetTwo}`); + } + const moveType = MoveType.ONE_OFF; cy.makeSocketRequest('game', 'targetedOneOff', { moveType, targetId: target.id, cardId: card.id, targetType, + // Nines return two cards, so they send a second target + ...(targetTwo && { targetIdTwo: targetTwo.id, targetTypeTwo: targetTwoType }), }); }); @@ -630,28 +638,36 @@ Cypress.Commands.add('playOneOffFromSevenOpponent', (card) => { }); }); -Cypress.Commands.add('playTargetedOneOffFromSevenOpponent', (card, target, targetType) => { - if (!hasValidSuitAndRank(card)) { - throw new Error(`Cannot play targeted one-off via seven with invalid card ${card}`); - } - if (!hasValidSuitAndRank(target)) { - throw new Error(`Cannot play targeted one-off via seven with invalid card ${target}`); - } - Cypress.log({ - displayName: 'Opponent seven targeted one-off', - name: 'Opponent plays one-off from seven', - message: printCard(card), - }); +Cypress.Commands.add( + 'playTargetedOneOffFromSevenOpponent', + (card, target, targetType, targetTwo, targetTwoType) => { + if (!hasValidSuitAndRank(card)) { + throw new Error(`Cannot play targeted one-off via seven with invalid card ${card}`); + } + if (!hasValidSuitAndRank(target)) { + throw new Error(`Cannot play targeted one-off via seven with invalid card ${target}`); + } + if (targetTwo && !hasValidSuitAndRank(targetTwo)) { + throw new Error(`Cannot play targeted one-off via seven with invalid second target ${targetTwo}`); + } + Cypress.log({ + displayName: 'Opponent seven targeted one-off', + name: 'Opponent plays one-off from seven', + message: printCard(card), + }); - const cardId = card.id; - const targetId = target.id; - cy.makeSocketRequest('game', 'seven/targetedOneOff', { - moveType: MoveType.SEVEN_ONE_OFF, - cardId, - targetId, - targetType, - }); -}); + const cardId = card.id; + const targetId = target.id; + cy.makeSocketRequest('game', 'seven/targetedOneOff', { + moveType: MoveType.SEVEN_ONE_OFF, + cardId, + targetId, + targetType, + // Nines return two cards, so they send a second target + ...(targetTwo && { targetIdTwo: targetTwo.id, targetTypeTwo: targetTwoType }), + }); + }, +); Cypress.Commands.add('passOpponent', (gameId = null) => { cy.log('Opponent Passes'); diff --git a/tests/unit/fixtures/gameStates/points.js b/tests/unit/fixtures/gameStates/points.js index 50596017d..09e095656 100644 --- a/tests/unit/fixtures/gameStates/points.js +++ b/tests/unit/fixtures/gameStates/points.js @@ -22,6 +22,8 @@ const gameStateRow = { oneOff: null, oneOffTarget: null, oneOffTargetType: null, + oneOffTargetTwo: null, + oneOffTargetTwoType: null, resolved: null, gameId: 1, playedBy: 0, @@ -29,6 +31,7 @@ const gameStateRow = { turn: 3, phase: GamePhase.MAIN, targetCard: null, + targetCardTwo: null, }; export const points = { @@ -68,6 +71,8 @@ export const points = { oneOff: null, oneOffTarget: null, oneOffTargetType: null, + oneOffTargetTwo: null, + oneOffTargetTwoType: null, resolved: null, playedCard: Card.TEN_OF_SPADES, gameId: 1, @@ -76,6 +81,7 @@ export const points = { turn: 3, phase: GamePhase.MAIN, targetCard: null, + targetCardTwo: null, }, gameStateRow: gameStateRow, @@ -192,6 +198,8 @@ export const points = { oneOff: null, oneOffTarget: null, oneOffTargetType: null, + oneOffTargetTwo: null, + oneOffTargetTwoType: null, lastEvent: { change: MoveType.POINTS, happened: true, @@ -205,6 +213,7 @@ export const points = { playedCard: 'TS', resolved: null, targetCard: null, + targetCardTwo: null, }, ], }, diff --git a/tests/unit/fixtures/gameStates/resolveNine.js b/tests/unit/fixtures/gameStates/resolveNine.js index 4c6e5800b..c95132aa4 100644 --- a/tests/unit/fixtures/gameStates/resolveNine.js +++ b/tests/unit/fixtures/gameStates/resolveNine.js @@ -8,16 +8,17 @@ const gameStateRow = { moveType: MoveType.RESOLVE, playedCard: null, targetCard: 'JC', + targetCardTwo: null, discardedCards: [], turn: 3, phase: GamePhase.MAIN, p0Hand: [], p0Points: [ 'AH', 'TH' ], p0FaceCards: [], - p1Hand: [], + p1Hand: [ 'JC' ], p1Points: [], p1FaceCards: [], - deck: [ 'JC', 'AC', '2C', '3C', '4C', '5C', '6C', '7C', '8C', 'TC', 'QC', 'KC', + deck: [ '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' ], @@ -25,6 +26,8 @@ const gameStateRow = { oneOff: null, oneOffTarget: null, oneOffTargetType: null, + oneOffTargetTwo: null, + oneOffTargetTwoType: null, twos: [], resolved: '9C', gameId: 1, @@ -40,6 +43,7 @@ export const resolveNine = { moveType: MoveType.RESOLVE, playedCard: null, targetCard: Card.JACK_OF_CLUBS, + targetCardTwo: null, discardedCards: [], turn: 3, phase: GamePhase.MAIN, @@ -49,12 +53,11 @@ export const resolveNine = { faceCards: [], }, p1: { - hand: [], + hand: [ Card.JACK_OF_CLUBS ], 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, @@ -74,6 +77,8 @@ export const resolveNine = { oneOff: null, oneOffTarget: null, oneOffTargetType: null, + oneOffTargetTwo: null, + oneOffTargetTwoType: null, twos: [], resolved: Card.NINE_OF_CLUBS, gameId: 1, @@ -90,7 +95,6 @@ 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, @@ -117,13 +121,15 @@ export const resolveNine = { lock: null, lockedAt: null, log: [ - 'The 9♣️ one-off resolves, putting the J♣️ on top of the deck.', + 'The 9♣️ one-off resolves, returning the J♣️ to definitelyNotTheGovernment6969\'s hand.', ], match: null, name: 'Test Game', oneOff: null, oneOffTarget: null, oneOffTargetType: null, + oneOffTargetTwo: null, + oneOffTargetTwoType: null, p0Ready: true, p0Rematch: null, p1Ready: true, @@ -145,7 +151,7 @@ export const resolveNine = { frozenId: null, }, { - hand: [], + hand: [ Card.JACK_OF_CLUBS ], points: [], faceCards: [], createdAt: '2024-07-14T12:59:47.709Z', @@ -174,6 +180,7 @@ export const resolveNine = { playedCard: null, resolved: '9C', targetCard: 'JC', + targetCardTwo: null, }, ], }, diff --git a/tests/unit/fixtures/gameStates/resolveThree.js b/tests/unit/fixtures/gameStates/resolveThree.js index 0f4fd66bc..783676079 100644 --- a/tests/unit/fixtures/gameStates/resolveThree.js +++ b/tests/unit/fixtures/gameStates/resolveThree.js @@ -9,6 +9,7 @@ const gameStateRow = { moveType: MoveType.RESOLVE_THREE, playedCard: null, targetCard: 'TH', + targetCardTwo: null, discardedCards: [], turn: 1, phase: GamePhase.MAIN, @@ -28,6 +29,8 @@ const gameStateRow = { oneOff: null, oneOffTarget: null, oneOffTargetType: null, + oneOffTargetTwo: null, + oneOffTargetTwoType: null, twos: [], resolved: '3S', gameId: 1, @@ -39,6 +42,7 @@ export const resolveThree = { moveType: MoveType.RESOLVE_THREE, playedCard: null, targetCard: Card.TEN_OF_HEARTS, + targetCardTwo: null, discardedCards: [], turn: 1, phase: GamePhase.MAIN, @@ -72,6 +76,8 @@ export const resolveThree = { oneOff: null, oneOffTarget: null, oneOffTargetType: null, + oneOffTargetTwo: null, + oneOffTargetTwoType: null, twos: [], resolved: Card.THREE_OF_SPADES, gameId: 1, @@ -155,6 +161,8 @@ export const resolveThree = { oneOff: null, oneOffTarget: null, oneOffTargetType: null, + oneOffTargetTwo: null, + oneOffTargetTwoType: null, turn: 1, log: [ 'myUsername took the 10♥️ from the Scrap pile to their hand.' ], chat: [], @@ -179,6 +187,7 @@ export const resolveThree = { playedCard: null, resolved: '3S', targetCard: 'TH', + targetCardTwo: null, }, ], }, diff --git a/utils/Transitions.json b/utils/Transitions.json index f0b252ba5..a334d0e61 100644 --- a/utils/Transitions.json +++ b/utils/Transitions.json @@ -5,9 +5,5 @@ "ENTER_FROM_UPPER_LEFT": "enter-from-upper-left", "ENTER_FROM_LEFT": "enter-from-left", "SLIDE_UP_DOWN": "slide-up-down", - "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" + "CARD_FLIP": "card-flip" } diff --git a/utils/rulesVersion.js b/utils/rulesVersion.js index 5026b1eee..eb036706b 100644 --- a/utils/rulesVersion.js +++ b/utils/rulesVersion.js @@ -24,7 +24,8 @@ * 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 (#1379) => Nine rework: put target card on top of the deck instead of returning it frozen to hand + * 3.0.0 (#1378) => Nine rework: return TWO cards to their controller's hand, neither frozen; any Queen blocks nines */ module.exports = { - CURRENT_RULES_VERSION: '2.0.0', + CURRENT_RULES_VERSION: '3.0.0', }; From ea58f40614d2389dbfa8a243dc0b837afbde74c8 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Wed, 16 Sep 2026 22:11:30 -0400 Subject: [PATCH 2/4] docs(nine-rework-summary): compact session summary through first PR draft --- nine-rework-summary.md | 213 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 nine-rework-summary.md diff --git a/nine-rework-summary.md b/nine-rework-summary.md new file mode 100644 index 000000000..9ef6d7877 --- /dev/null +++ b/nine-rework-summary.md @@ -0,0 +1,213 @@ +# Session Summary — Nine Rework (Return Two Cards, Unfrozen) + +**Branch:** `feat/nines-discard-two` · **Commit:** `8af93434` · **PR:** [#1378](https://github.com/cuttle-cards/cuttle/pull/1378) +**Scope:** 42 files, +1347 / −652, plus one new helper file. + +--- + +## 1. The task + +Change the Nine one-off effect to return **two** cards to their controller's hand, and drop +the freeze so both returned cards are immediately replayable. Ship it as a **beta for the +Spades season**, announced on the home page, with a community poll toward the end of the +year deciding whether it stays. + +Original request also covered: updating `resolve/nine.js` and `one-off/validate.js`, a major +`rulesVersion` bump, migrating all affected e2e/unit tests, and creating the announcement. + +--- + +## 2. Decisions the user made during planning + +These were resolved through questions before implementation began: + +| Question | Decision | +|---|---| +| What does "return two cards" mean? | **True dual-target select** — the player picks two separate targets on the opponent's board. | +| Minimum targets | Nine requires **exactly two**; fewer than 2 legal targets makes it unplayable. | +| Target type mixing | **Any mix** of point / faceCard / jack. | +| Queen interaction | A Queen protects everything else, leaving itself the only legal target, so two legal targets are impossible → **any Queen (1+) blocks a Nine outright**. Enforced in the backend validator **and** pre-emptively in the MoveChoiceCard. Twos keep their existing rules. | +| Jack stacks | **Top jack only** — never a buried one. The top jack **and** the point card it sits on may both be selected (inclusive). | +| Resolution order | **Simultaneous.** Both targets go to the hand of whoever controlled them *before* resolution. So jack + its own host → **both to the jack holder's hand**. | +| AI bot | Update it to play two-target Nines (not exclude it). | +| Selection UX | On-board selection + a Confirm button styled like `BaseDialog`'s `v-fab.dialog-activator`. | +| Dead freeze code | Define a rollback plan; keep the plumbing dormant, with a cleanup plan if the change is kept. | +| Targets vanishing mid-resolution | Can't happen; add a sanity no-op guard anyway. | + +--- + +## 3. Critical discoveries during research + +**`isFrozen` was never persisted.** It was *re-derived on unpack* in +`api/helpers/game-states/unpack-gamestate.js` from the rule *"resolved card is a 9 AND this +hand card equals `targetCard`"*. So removing freezing required deleting that derivation too — +removing only the `isFrozen: true` writes would have let the DB round-trip re-freeze target #1. + +**`resolve/nine.js` was the only producer of `isFrozen: true` in the entire codebase.** +Everything else merely consumes it. Removing the freeze therefore killed the whole mechanic. + +**Replays are safe.** Spectating/replay (`api/controllers/game/spectate/join.js`) only +`unpackGamestate`s *stored* rows — it never re-executes moves through current validate/resolve +helpers. Historical games are never re-validated under the new rules. + +**`validate-gamestate.js` is an explicit whitelist.** Any GameState field not listed there is +silently dropped before packing — an easy way to lose the new fields. + +**Both validators duplicated the whole `case 2: case 9:` block** (`one-off/validate.js` and +`seven-one-off/validate.js`), so Nine rules would have had to be maintained in two places. + +--- + +## 4. What was implemented + +### Backend + +- **`api/models/GameStateRow.js`** — three additive nullable columns: `oneOffTargetTwo`, + `oneOffTargetTwoType`, `targetCardTwo`. Chosen over widening the existing singular fields, + which twos, the seven flow, the log, the socket payload and the store all read. +- **Plumbing** — `pack-gamestate.js`, `unpack-gamestate.js` (+ **freeze derivation deleted**), + `validate-gamestate.js`, `create-socket-events.js`, `deal-cards.js`, + `load-fixture-gamestate.js`. +- **`api/helpers/game-states/validate-nine-targets.js`** *(new)* — shared Nine validation + returning an error message or `null`; called from both validators. Covers: any-queen block, + ≥2 legal targets, both target pairs present, no duplicate target, top-jack-only. +- **`resolve/nine.js`** — rewritten for simultaneous resolution. Snapshots each target's + controller before mutation, processes jacks first, uses fresh `findIndex` at splice time, + splices jacks by index rather than `pop()`, scraps non-targeted attachments, no-ops missing + targets. +- **`resolve/execute.js`** — sets `targetCardTwo`, nulls both new fields; **also fixes a + pre-existing leak** where the fizzle path never cleared the target slots. +- **`get-log.js`** — names both returned cards, drops "It cannot be played next turn." +- **`ai/get-move-bodies-for-move-type.js`** — enumerates two-target pairs for Nines, skips + generation when blocked; also fixes a pre-existing bug where jack bodies omitted `targetType`. +- **`utils/rulesVersion.js`** — `2.0.0` with a history line. +- **`docs/game-state-api.md`** — new columns, GameState fields, socket payload, and the Nine + targeting/queen rules. + +### Frontend + +- **`GameView.vue`** — replaced dead `targetType`/`nineTargetIndex` state (and an unused + `nineTarget` computed) with a real `selectedNineTargets` list; `resolveTargetDescriptor` + split from submission; toggle-to-deselect; third click ignored; `validMoves` returns `[]` + for rank 9 when any queen is present. **Rank 2 still fires on first click.** +- **`TargetSelectionOverlay.vue`** — `(n/2)` progress header and a Confirm button carrying + `BaseDialog`'s pulse-glow/shimmer treatment and reduced-motion guard. +- **`GameCard.vue`** — new `isSelectedTarget` prop → pink border + tint + check badge on a + light disc. +- **`stores/game.js`** — forwards the second target on both the normal and seven paths; + `oneOffTargetTwo` / `lastEventTargetTypeTwo` refs hydrated, reset and exported. +- **Counter dialogs** — `GameDialogs.vue`, `CannotCounterDialog.vue`, + `ChooseWhetherToCounterDialog.vue` now show both cards at stake. +- **`MoveChoiceOverlay.vue`** — queen check moved inside the rank branch; new + `numValidNineTargets` computed. + +### i18n (all five locales: en, de, es, fr, ukr) + +Rewrote `game.moves.effects.9`, `rules.oneoffs.nineDescription`, `rules.4Players.rule5` +(mentioned the freeze twice), and `game.snackbar.global.blockedByMultipleQueens` (now Twos +only). Added `game.snackbar.oneOffs.nine.*` (5 keys) and +`game.moves.disabledMove.{queenBlocksNine, nineNeedsTwoTargets}`. Verified identical key +positions across all files and no untranslated English placeholders. + +### Announcement + +Via the `announcement` skill — `ninesBuff2026Announcement`, "Rules Beta: Nines Return Two +Cards", two 9s as display cards, **2026-09-08 → 2027-01-05**, two sections (the buff +rationale; the Spades-season test + year-end poll). Orphaned keys from the previous Diamonds +announcement removed from every locale. + +### Tests + +Extended both shared Cypress commands to an optional two-target form (no churn for twos). +Rewrote `9_nines.spec.js` (19 tests): deleted the `assertCardIsFrozen` helper and two +freeze-only tests, converted the stalemate/reload one to assert continued *playability*, and +added coverage for mixed pairs, **jack + its own host**, deselect, confirm-gating, queen +block, `<2` targets, and the seven path. Also updated `targeting_cleanup.spec.js`, +`opponent_sevens.spec.js`, `player_sevens.spec.js`, `handLimit.spec.js`, and three unit +fixtures. + +--- + +## 5. Bugs found *during* testing (all fixed) + +1. **Unit fixtures** — `points.js` / `resolveThree.js` needed the new fields; the socket + *event* carries `targetCardTwo` only when truthy, so a first pass over-added it there. +2. **Self-inflicted goal interaction** — gave the opponent two Kings to have two non-Queen + royals, but two Kings drop their goal to 10 and the fixture had exactly 10 points. The + opponent won instantly; the failure screenshot showed "YOU LOSE". Switched to a four. +3. **A four is scuttleable by a nine**, which re-enabled a scuttle choice a test asserts is + disabled. Swapped that second target for a royal. +4. **Pre-existing fizzle leak** — a new assertion in `targeting_cleanup` exposed that + `resolve/execute.js` never cleared `oneOffTarget`/`oneOffTargetType` on fizzle (masked + because the next one-off overwrote them). Now clears all four slots. +5. **Missed two `player_sevens` tests** on the first sweep (that file was wrongly filtered out + of a grep) — one targeted a Queen, the other never confirmed. +6. **Selection styling was invisible** — the green "valid target" overlay renders *on top* of + the pink border/tint, so selected cards read as *disabled*. Fixed by suppressing the green + overlay once a card is selected; tint raised to 0.35 and the badge given a light disc, + because dark royal art washed out 28% pink. + +--- + +## 6. Verification results (local) + +| Check | Result | +|---|---| +| `npm run lint` | clean | +| `npm run test:unit` | 3 client + 83 sails passing (stable across 3 runs) | +| `one-offs/**` (12 specs) | **118 / 118** | +| handLimit, basicMoves, vsAI, reconnecting | **64 / 64** | +| Full `cypress run` (34 specs) | 0 failures through 11 specs, incl. `home.spec.js` with the announcement live — **stopped early at the user's request**; CI verifies the rest | +| Manual UI pass | Verified via captured screenshots (selection states, queen-blocked) | + +An early `google.spec.js` failure was investigated and proved to be **flaky DB-state carryover** +from aborted runs — it passes at HEAD and with the changes across repeated runs. + +--- + +## 7. Outstanding items + +- ⚠️ **Manual migration required before deploy.** No migration tooling exists; staging/prod are + `migrate: 'safe'`: + ```sql + ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "oneOffTargetTwo" text; + ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "oneOffTargetTwoType" text; + ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "targetCardTwo" text; + ``` +- **Follow-up issue at merge time** — if the poll *keeps* the change, delete the now-dead freeze + plumbing (validator guards, snowflake UI, `convert-str-to-card`'s `isFrozen` input, + `SnackBarError.FROZEN_CARD`, related i18n). Left dormant so a revert stays a clean + `git revert`. +- **Branch name mismatch** — `feat/nines-discard-two`, but the effect *returns* cards rather than + discarding them. PR title/body are accurate; only the branch name is off. +- **PR has no linked issue** — template's `Resolves #` left blank. +- **Pre-existing bug, deliberately not fixed** — `src/stores/game.js` reads + `lastEvent?.oneOffTargetType`, but the backend only ever puts that field on the game object, + never inside `lastEvent`. It has always been `null`, making the jack/faceCard branches of the + card-transition animations dead code. Fixing it would silently enable animations that have + never run — out of scope here, flagged for a decision. +- **Pre-existing i18n order drift** — `es.json` / `fr.json` diverge from `en.json` at an + unrelated `game.dialogs` key. Predates this work; not touched. +- **Rules-page art is now stale** — `/img/rulesView/oneoffs_nine.png` and the animated GIF in the + external `cuttle-cards/cuttle-assets` repo still depict the old behavior. Can't be fixed here. + +--- + +## 8. Rollback plan (if the poll reverts) + +1. `git revert` the PR. Because freeze is derived at unpack rather than stored, this restores the + old behavior with **zero data migration**. +2. Bump `CURRENT_RULES_VERSION` **forward** to `3.0.0` with a history line noting the revert — + never reuse or decrement, since games are stamped with it. +3. Leave the three columns in place (nullable, unused); trial-era games keep their data. +4. Remove the announcement entry. +5. Accept one cosmetic wrinkle: with the derivation restored, replaying a *trial-era* game + re-derives a freeze on its first target, showing a frozen card that wasn't frozen when played. + +--- + +## 9. Environment note + +The dev stack booted for testing was still running on **1337** and **8080** at session end +(PIDs 87880 / 87897) — the user had shut down their own instance to free the ports, so this is +a replacement instance awaiting their decision. From b7af6754b430e3e38c95a14307ed5e4a69cca532 Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Sat, 19 Sep 2026 10:19:23 -0400 Subject: [PATCH 3/4] test(playground): update the nine scenarios for two-target nines The three nine recording scenarios came from the topdeck rework (#1379) and each played a nine at a single target, which is no longer a legal move. Give each one a second target so they run again, and rewrite the comments and the post-resolve assertions to describe cards returning to hand rather than travelling to the deck. 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, 26 insertions(+), 10 deletions(-) diff --git a/tests/e2e/specs/playground/videoPlayground.spec.js b/tests/e2e/specs/playground/videoPlayground.spec.js index ca0b7b074..278ddac1b 100644 --- a/tests/e2e/specs/playground/videoPlayground.spec.js +++ b/tests/e2e/specs/playground/videoPlayground.spec.js @@ -720,20 +720,24 @@ describe('Video Playground', () => { cy.get('#turn-indicator').contains('YOUR TURN'); cy.wait(2500); - // Player answers with a Nine on the Jack + // Player answers with a Nine, taking both the Jack and the point card under it 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-face-card=11-1]').click(); cy.wait(1000); + cy.get('[data-opponent-point-card=10-3]').click(); + cy.wait(1000); + cy.get('[data-cy=confirm-targets]').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 + // Both cards head for the opponent's hand: the Jack and the Ten it was stealing were + // theirs to control, so they go back together cy.get('[data-opponent-face-card=11-1]').should('not.exist'); - cy.get('[data-player-point-card=10-3]').should('exist'); + cy.get('[data-opponent-point-card=10-3]').should('not.exist'); cy.wait(3000); }); @@ -758,18 +762,23 @@ describe('Video Playground', () => { cy.get('#turn-indicator').contains('YOUR TURN'); cy.wait(2500); - // Player answers with a Nine on the point card that put them in check + // Player answers with a Nine, taking back both of the opponent's point cards 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('[data-opponent-point-card=10-0]').click(); + cy.wait(1000); + cy.get('[data-cy=confirm-targets]').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 + // Both Tens slide up to the opponent's hand, dropping them from 20 points to 0 cy.get('[data-opponent-point-card=10-3]').should('not.exist'); + cy.get('[data-opponent-point-card=10-0]').should('not.exist'); cy.wait(3000); }); @@ -792,15 +801,22 @@ describe('Video Playground', () => { // 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'); + // Opponent answers by returning the glasses and the point card under them + cy.playTargetedOneOffOpponent( + Card.NINE_OF_CLUBS, + Card.EIGHT_OF_HEARTS, + 'faceCard', + Card.TEN_OF_HEARTS, + 'point', + ); 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 + // Both cards slide down to the player's hand, and with the glasses gone the + // opponent's hand goes back to card backs cy.get('[data-player-face-card=8-2]').should('not.exist'); + cy.get('[data-player-point-card=10-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); From ef93417253dfc5d1da5760b79d9e88685c5ccb4d Mon Sep 17 00:00:00 2001 From: Ryan Emberling Date: Sat, 19 Sep 2026 12:34:39 -0400 Subject: [PATCH 4/4] chore(rules): renumber the nine rework to 4.0.0 The random-fours beta (#1384) goes in first and takes 3.0.0, so this rework moves to 4.0.0. Adds the fours history line and updates the one doc reference that named the old number. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV --- docs/game-state-api.md | 2 +- utils/rulesVersion.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/game-state-api.md b/docs/game-state-api.md index 7e12fb468..06038bd5b 100644 --- a/docs/game-state-api.md +++ b/docs/game-state-api.md @@ -159,7 +159,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 and can’t be played this turn. Nothing freezes cards as of rules version 3.0.0 (9's used to, but no longer do), so this is always false; the field is retained so the mechanic can be restored if the rules change back +* isFrozen: `Boolean` \- whether the card is currently frozen and can’t be played this turn. Nothing freezes cards as of rules version 4.0.0 (9's used to, but no longer do), so this is always false; the field is retained so the mechanic can be restored if the rules change back ## Player diff --git a/utils/rulesVersion.js b/utils/rulesVersion.js index eb036706b..96727b761 100644 --- a/utils/rulesVersion.js +++ b/utils/rulesVersion.js @@ -24,8 +24,9 @@ * 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 (#1379) => Nine rework: put target card on top of the deck instead of returning it frozen to hand - * 3.0.0 (#1378) => Nine rework: return TWO cards to their controller's hand, neither frozen; any Queen blocks nines + * 3.0.0 (#1384) => Four rework: opponent discards two cards at random instead of choosing + * 4.0.0 (#1378) => Nine rework: return TWO cards to their controller's hand, neither frozen; any Queen blocks nines */ module.exports = { - CURRENT_RULES_VERSION: '3.0.0', + CURRENT_RULES_VERSION: '4.0.0', };