diff --git a/api/controllers/game/load-fixture-gamestate.js b/api/controllers/game/load-fixture-gamestate.js index 0c757005c..cd6b710ec 100644 --- a/api/controllers/game/load-fixture-gamestate.js +++ b/api/controllers/game/load-fixture-gamestate.js @@ -22,6 +22,12 @@ module.exports = async function (req, res) { secondCard = null, scrap = [], deck, + // Overrides so tests can stage states no live move can produce anymore -- notably the + // legacy RESOLVING_FOUR phase, which stored games still hold and replay. + phase = GamePhase.MAIN, + turn = 0, + resolved = null, + oneOff = null, } = req.body; const allFixtureCards = new Set(Object.values(req.body).flat() @@ -58,15 +64,15 @@ module.exports = async function (req, res) { scrap: populatedScrap, twos: [], discardedCards: [], - oneOff: null, + oneOff, oneOffTarget: null, - resolved: null, + resolved, playedCard: null, gameId: game.id, playedBy: 0, moveType: MoveType.LOADFIXTURE, - turn: 0, - phase: GamePhase.MAIN, + turn, + phase, targetCard: null, }; diff --git a/api/helpers/game-states/ai/get-legal-moves.js b/api/helpers/game-states/ai/get-legal-moves.js index 5eeb71382..cc9d7e6a4 100644 --- a/api/helpers/game-states/ai/get-legal-moves.js +++ b/api/helpers/game-states/ai/get-legal-moves.js @@ -24,7 +24,9 @@ module.exports = { }, sync: true, fn: ({ currentState, playedBy, priorStates }, exits) => { - const disallowedMoveTypes = [ 'DEAL', 'CONCEDE', 'STALEMATE_REQUEST', 'STALEMATE_ACCEPT', 'FIZZLE', 'LOADFIXTURE' ]; + // RESOLVE_FOUR is retained in the enum so stored games still render, but its move helper is + // gone -- fours resolve immediately now -- so it must not be looked up here. + const disallowedMoveTypes = [ 'DEAL', 'CONCEDE', 'STALEMATE_REQUEST', 'STALEMATE_ACCEPT', 'FIZZLE', 'LOADFIXTURE', 'RESOLVE_FOUR' ]; const moveTypes = Object.values(_.omit(MoveType, disallowedMoveTypes)); const res = []; for (let moveType of moveTypes) { 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 37fee3fab..60322e2b6 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 @@ -130,30 +130,6 @@ module.exports = { res = scrap.map((card) => ({ moveType, playedBy, cardId: card.id })); break; - case MoveType.RESOLVE_FOUR: { - - if (playerHand.length === 1) { - res = [ - { - moveType, - playedBy, - cardId1: playerHand[0].id, - }, - ]; - } - - for (let i = 0; i < playerHand.length; i++) { - for (let j = i + 1; j < playerHand.length; j++) { - res.push({ - moveType, - playedBy, - cardId1: playerHand[i].id, - cardId2: playerHand[j].id - }); - } - } - break; - } case MoveType.RESOLVE_FIVE: if (!playerHand.length) { diff --git a/api/helpers/game-states/get-log.js b/api/helpers/game-states/get-log.js index 1cae92aee..d6839aace 100644 --- a/api/helpers/game-states/get-log.js +++ b/api/helpers/game-states/get-log.js @@ -91,8 +91,17 @@ module.exports = { return `The ${resolvedCardName} resolves; the ${targetCardName} is scrapped.`; case 3: return `The ${resolvedCardName} one-off resolves; ${opponent} will draw one card of their choice from the Scrap pile.`; - case 4: - return `The ${resolvedCardName} one-off resolves; ${player} must discard two cards.`; + case 4: { + // Legacy rows (rules < 3.0.0) deferred the discard to a separate resolveFour + // move, so the cards are named on that row and this one carries none. + if (!discardedCards?.length) { + return `The ${resolvedCardName} one-off resolves; ${player} must discard two cards.`; + } + const discardNames = discardedCards.map(getFullCardName); + const discardList = + discardNames.length > 1 ? `the ${discardNames[0]} and the ${discardNames[1]}` : `the ${discardNames[0]}`; + return `The ${resolvedCardName} one-off resolves, discarding ${discardList} at random from ${player}'s hand.`; + } case 5: return `The ${resolvedCardName} one-off resolves; ${opponent} must discard 1 card, and will draw up to 3.`; case 6: diff --git a/api/helpers/game-states/moves/one-off/validate.js b/api/helpers/game-states/moves/one-off/validate.js index e1cb41517..4c2d9de29 100644 --- a/api/helpers/game-states/moves/one-off/validate.js +++ b/api/helpers/game-states/moves/one-off/validate.js @@ -125,11 +125,9 @@ module.exports = { } return exits.success(); - // Four requires opponent to have cards in hand + // Fours discard at random, so an empty hand just discards nothing. The arm has to + // stay: the default below rejects any rank that isn't explicitly allowed here. case 4: - if (!opponent.hand.length) { - throw new Error('game.snackbar.oneOffs.four.opponentHasNoCards'); - } return exits.success(); // Five and sevens require cards in deck diff --git a/api/helpers/game-states/moves/resolve-four/execute.js b/api/helpers/game-states/moves/resolve-four/execute.js deleted file mode 100644 index 9a5a8f723..000000000 --- a/api/helpers/game-states/moves/resolve-four/execute.js +++ /dev/null @@ -1,73 +0,0 @@ -const GamePhase = require('../../../../../utils/GamePhase.json'); - -module.exports = { - friendlyName: 'Resolve four', - - description: 'Returns new GameState resulting from requested resolve four move', - - inputs: { - currentState: { - type: 'ref', - description: 'The latest GameState before the requesting player resolves a four card', - required: true, - }, - /** - * @param { Object } requestedMove - Object describing the request to resolve a four - * @param { String } requestedMove.cardId1 - First card to be discarded - * @param { String } requestedMove.cardId2 - Second card to be discarded - * @param { MoveType.RESOLVE_FOUR } requestedMove.moveType - Specifies that this is a Resolve Four move - */ - requestedMove: { - type: 'ref', - description: 'The move being requested. Specifies which player is asking to resolve a four', - }, - playedBy: { - type: 'number', - description: 'Player number of player requesting move.', - }, - priorStates: { - type: 'ref', - description: 'List of packed gameStateRows for this game\'s prior states', - required: true, - } - }, - sync: true, // synchronous helper - fn: ({ currentState, requestedMove, playedBy }, exits) => { - const { cardId1, cardId2 } = requestedMove; - let result = _.cloneDeep(currentState); - - const player = playedBy ? result.p1 : result.p0; - const discardedCards = []; - - const cardIndex1 = player.hand.findIndex(({ id }) => id === cardId1); - - discardedCards.push(player.hand[cardIndex1]); - result.scrap.push(result.oneOff, ...player.hand.splice(cardIndex1, 1)); - - const cardIndex2 = cardId2 ? player.hand.findIndex(({ id }) => id === cardId2) : -1; - - if (cardIndex2 !== -1) { - discardedCards.push(player.hand[cardIndex2]); - result.scrap.push(...player.hand.splice(cardIndex2, 1)); - } - - // active player is the opposite of the one who discarded - const activePlayer = playedBy ? result.p0 : result.p1; - const playerMustDiscard = activePlayer.hand.length > 8; - - result = { - ...result, - ...requestedMove, - phase: playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN, - turn: playerMustDiscard ? result.turn : result.turn + 1, - playedBy, - playedCard: null, - targetCard: null, - discardedCards, - resolved: result.oneOff, - oneOff: null, - }; - - return exits.success(result); - }, -}; diff --git a/api/helpers/game-states/moves/resolve-four/validate.js b/api/helpers/game-states/moves/resolve-four/validate.js deleted file mode 100644 index 63a7cfa89..000000000 --- a/api/helpers/game-states/moves/resolve-four/validate.js +++ /dev/null @@ -1,67 +0,0 @@ -const GamePhase = require('../../../../../utils/GamePhase.json'); -const BadRequestError = require('../../../../errors/badRequestError'); - -module.exports = { - friendlyName: 'Validate request to resolve a four', - - description: 'Verifies whether a request to resolve a four is legal, throwing explanatory error if not.', - - inputs: { - currentState: { - type: 'ref', - descriptions: 'Object containing the current game state', - required: true, - }, - /** - * @param { Object } requestedMove - Object describing the request to resolve a four - * @param { String } requestedMove.cardId1 - First card to be discarded - * @param { String } requestedMove.cardId2 - Second card to be discarded - * @param { MoveType.RESOLVE_FOUR } requestedMove.moveType - Specifies that this is a Resolve Four move - */ - requestedMove: { - type: 'ref', - description: 'Object containing data needed for current move', - required: true, - }, - playedBy: { - type: 'number', - description: 'Player number of player requesting move', - required: true, - }, - priorStates: { - type: 'ref', - description: 'List of packed gameStateRows for this game\'s prior states', - required: true, - } - }, - sync: true, - fn: ({ requestedMove, currentState, playedBy }, exits) => { - try { - if (currentState.turn % 2 === playedBy) { - throw new BadRequestError('game.snackbar.oneOffs.four.notYourTurn'); - } - - if (currentState.phase !== GamePhase.RESOLVING_FOUR) { - throw new BadRequestError('game.snackbar.oneOffs.four.notResolvingFourPhase'); - } - - const player = playedBy ? currentState.p1 : currentState.p0; - const { cardId1, cardId2 } = requestedMove; - - const selectedCard1 = player.hand.find(card => card.id === cardId1); - const selectedCard2 = player.hand.find(card => card.id === cardId2); - - if ((requestedMove.cardId1 && !selectedCard1) || (requestedMove.cardId2 && !selectedCard2)) { - throw new BadRequestError('game.snackbar.oneOffs.four.mustSelectCards'); - } - - if (!selectedCard2 && player.hand.length > 1) { - throw new BadRequestError('game.snackbar.oneOffs.four.mustSelectCards'); - } - - return exits.success(); - } catch (err) { - return exits.error(err); - } - }, -}; diff --git a/api/helpers/game-states/moves/resolve/execute.js b/api/helpers/game-states/moves/resolve/execute.js index 728b0eedb..558af2e19 100644 --- a/api/helpers/game-states/moves/resolve/execute.js +++ b/api/helpers/game-states/moves/resolve/execute.js @@ -79,8 +79,10 @@ module.exports = { case 6: result = sails.helpers.gameStates.moves.resolve.six(result); break; - case 3: case 4: + result = sails.helpers.gameStates.moves.resolve.four(result, playedBy); + break; + case 3: case 5: case 7: // These one-offs require an additional user input after resolving diff --git a/api/helpers/game-states/moves/resolve/four.js b/api/helpers/game-states/moves/resolve/four.js new file mode 100644 index 000000000..88670ea9f --- /dev/null +++ b/api/helpers/game-states/moves/resolve/four.js @@ -0,0 +1,39 @@ +module.exports = { + friendlyName: 'Resolve Four One-Off', + + description: + 'Returns new GameState resulting from resolving a four, which discards two cards at random from the ' + + 'hand of the player resolving it. A hand of fewer than two cards discards whatever it holds.', + + inputs: { + currentState: { + type: 'ref', + description: 'The latest GameState before the four discards its two cards', + required: true, + }, + playedBy: { + type: 'number', + description: 'Player number of player requesting move.', + } + }, + + sync: true, // synchronous helper + fn: ({ currentState, playedBy }, exits) => { + const result = _.cloneDeep(currentState); + + // The player resolving the one-off is the opposite of the one who played it, so `playedBy` + // identifies the player whose hand is discarded -- not the four's caster. + const victim = playedBy ? result.p1 : result.p0; + + // sampleSize returns *up to* two cards, so a short hand simply discards everything it has + const discardedCards = _.sampleSize(victim.hand, 2); + const discardedIds = new Set(discardedCards.map(({ id }) => id)); + + // Filter by id rather than splicing -- sampleSize returns the cards, not their positions + victim.hand = victim.hand.filter(({ id }) => !discardedIds.has(id)); + result.scrap.push(...discardedCards); + result.discardedCards = discardedCards; + + 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..616aec686 100644 --- a/api/helpers/game-states/moves/seven-one-off/validate.js +++ b/api/helpers/game-states/moves/seven-one-off/validate.js @@ -114,10 +114,9 @@ module.exports = { return exits.success(); // Four requires opponent to have cards in hand + // Fours discard at random, so an empty hand just discards nothing. The arm has to + // stay: the default below rejects any rank that isn't explicitly allowed here. case 4: - if (!opponent.hand.length) { - throw new BadRequestError('game.snackbar.oneOffs.four.opponentHasNoCards'); - } return exits.success(); // Five and sevens require cards in deck diff --git a/api/models/GameStateRow.js b/api/models/GameStateRow.js index f04539b53..1e77c3e0f 100644 --- a/api/models/GameStateRow.js +++ b/api/models/GameStateRow.js @@ -66,7 +66,7 @@ module.exports = { * 1 - MAIN * 2 - COUNTERING * 3 - RESOLVING_THREE - * 4 - RESOLVING_FOUR + * 4 - RESOLVING_FOUR (legacy; rules < 3.0.0 only -- fours resolve immediately now) * 5 - RESOLVING_FIVE * 7 - RESOLVING_SEVEN */ diff --git a/api/policies/hasValidMoveBody.js b/api/policies/hasValidMoveBody.js index 69a3a0ed4..6fcf808ce 100644 --- a/api/policies/hasValidMoveBody.js +++ b/api/policies/hasValidMoveBody.js @@ -10,7 +10,7 @@ const DeckIds = require('../../utils/DeckIds.json'); * */ module.exports = function (req, res, next) { - const { moveType, cardId, cardId1, cardId2, targetId, targetType } = req.body; + const { moveType, cardId, targetId, targetType } = req.body; switch (moveType) { // These moves require no extra data @@ -43,22 +43,6 @@ module.exports = function (req, res, next) { } return next(); - // Requires `cardId1` and optionally accepts `cardId2` - case MoveType.RESOLVE_FOUR: - { - if (!cardId1) { - return res.badRequest({ message: 'Must specify a card' }); - } - - if (!DeckIds.includes(cardId1)) { - return res.badRequest({ message: `${cardId1} is not a valid cardId` }); - } - - if (cardId2 && !DeckIds.includes(cardId2)) { - return res.badRequest({ message: `${cardId2} is not a valid cardId` }); - } - } - return next(); // Requires a non-empty `discardedCards` array of valid card IDs case MoveType.DISCARD_TO_HAND_LIMIT: diff --git a/docs/game-rules.md b/docs/game-rules.md index ca77309b3..bf389741b 100644 --- a/docs/game-rules.md +++ b/docs/game-rules.md @@ -44,7 +44,7 @@ On your turn you must make exactly one of the following actions below. 1. Counter **target One-Off** (play this when an opponent plays a **One-Off** effect to stop it from happening) 2. Scrap **target Royal or Glasses Eight** - **Three**: Choose one card from the **Scrap Pile** and put it in your hand. -- **Four**: Your opponent discards two cards of their choice from their hand to the **Scrap Pile** +- **Four**: Two cards are discarded at random from your opponent's hand to the **Scrap Pile**. If they hold fewer than two cards, they discard whatever they have. - **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. diff --git a/docs/game-state-api.md b/docs/game-state-api.md index 206f52c19..df19137d2 100644 --- a/docs/game-state-api.md +++ b/docs/game-state-api.md @@ -27,7 +27,7 @@ Enum describing the phase of a turn the game is currently in. Used to validate w * counter * resolve * resolvingThree \- Picking card from scrap. Only legal move is resolveThree -* resolvingFour \- Choosing cards to discard due to opponent four. Only legal move is resolveFour +* resolvingFour \- **Legacy (rules \< 3.0.0).** Choosing cards to discard due to opponent four; the only legal move was resolveFour. As of rules 3.0.0 a four discards two cards at random the moment it resolves, so no new game enters this phase. The value is retained because stored games still hold it and are re-rendered on replay. * resolvingFive \- Choosing card to discard before drawing. Only legal move is resolveFive * resolvingSeven \- Picking one of the top cards from the deck. Must play a seven move next. Legal next moves: * sevenPoints @@ -56,7 +56,7 @@ Enum designating which kind of move was made. * resolve * fizzle \- a one-off was countered, so it and everything played on it are scrapped * resolveThree (picking a card from the scrap) -* resolveFour (discarding from hand) +* resolveFour (discarding from hand) \- **Legacy (rules \< 3.0.0);** no longer accepted, retained so stored games still render * resolveFive (discarding before drawing) * discardToHandLimit * sevenPoints diff --git a/src/plugins/sockets/inGameEvents.js b/src/plugins/sockets/inGameEvents.js index f239d1bd8..a4e9d12c6 100644 --- a/src/plugins/sockets/inGameEvents.js +++ b/src/plugins/sockets/inGameEvents.js @@ -63,6 +63,15 @@ export async function handleInGameEvents(evData, newRoute = null) { break; case SocketEvent.RESOLVE: switch (evData.game.lastEvent?.oneOff?.rank) { + // playedBy on a resolve is the player who resolved it -- the one whose hand was + // discarded -- so anyone else watching is the caster, who needs the reveal + case 4: + if (evData.playedBy !== gameStore.myPNum) { + await gameStore.processFours(evData.discardedCards, evData.game); + } else { + gameStore.updateGame(evData.game); + } + break; case 7: await gameStore.processSevens(evData.game); break; @@ -80,6 +89,8 @@ export async function handleInGameEvents(evData, newRoute = null) { case SocketEvent.RESOLVE_THREE: await gameStore.processThrees(evData.chosenCard, evData.game); break; + // Legacy: fours resolve immediately as of rules 3.0.0, but stored games still step + // through a separate resolveFour frame, and this switch has no default to fall back on case SocketEvent.RESOLVE_FOUR: if (evData.playedBy !== gameStore.myPNum) { await gameStore.processFours(evData.discardedCards, evData.game); diff --git a/src/routes/game/GameView.vue b/src/routes/game/GameView.vue index 6eab6d613..c2a901949 100644 --- a/src/routes/game/GameView.vue +++ b/src/routes/game/GameView.vue @@ -466,7 +466,6 @@ export default { targeting: false, targetingMoveName: null, targetingMoveDisplayName: null, - showFourDialog: false, topCardIsSelected: false, secondCardIsSelected: false, showHistoryDrawer: false, diff --git a/src/routes/game/components/dialogs/GameDialogs.vue b/src/routes/game/components/dialogs/GameDialogs.vue index ef63e3daf..e13211967 100644 --- a/src/routes/game/components/dialogs/GameDialogs.vue +++ b/src/routes/game/components/dialogs/GameDialogs.vue @@ -154,13 +154,17 @@ export default { this.gameStore.requestCounter(twoId).then(this.clearSelection) .catch(this.handleError); }, + // Legacy: no live game can enter the four-discard phase as of rules 3.0.0, but the dialog + // is still rendered when replaying a stored game that did, so the handler stays wired. discard(cardIds) { const [ cardId1 ] = cardIds; const cardId2 = cardIds.length > 1 ? cardIds[1] : null; - this.gameStore.requestDiscard({ - cardId1, - cardId2, - }); + this.gameStore + .requestDiscard({ + cardId1, + cardId2, + }) + .catch(this.handleError); }, discardToHandLimit(cardIds) { this.gameStore.requestDiscardToHandLimit(cardIds); diff --git a/src/routes/game/components/dialogs/components/FourDialog.vue b/src/routes/game/components/dialogs/components/FourDialog.vue index 429e0554c..57d29d9c6 100644 --- a/src/routes/game/components/dialogs/components/FourDialog.vue +++ b/src/routes/game/components/dialogs/components/FourDialog.vue @@ -1,3 +1,8 @@ +