From 4fce7acbb34364cab514bc823fa1657b73d56473 Mon Sep 17 00:00:00 2001 From: Alex Nisnevich Date: Fri, 12 May 2023 02:11:09 -0700 Subject: [PATCH 1/4] Allow multi-selection in target['choose'] --- src/common/reducers/handlers/game/board.ts | 4 +- src/common/reducers/handlers/game/cards.ts | 6 +- src/common/store/defaultGameState.ts | 1 + src/common/types.d.ts | 1 + src/common/util/cards.ts | 4 +- src/common/util/game.ts | 3 +- src/common/vocabulary/targets.ts | 79 +++++++++++++--------- 7 files changed, 59 insertions(+), 39 deletions(-) diff --git a/src/common/reducers/handlers/game/board.ts b/src/common/reducers/handlers/game/board.ts index a40b779f..8ca46365 100644 --- a/src/common/reducers/handlers/game/board.ts +++ b/src/common/reducers/handlers/game/board.ts @@ -21,7 +21,7 @@ function selectTile(state: State, tile: w.HexId | null): State { function resetTargetAndStatus(player: PlayerState): void { player.status = { message: '', type: '' }; - player.target = { choosing: false, chosen: null, possibleCardsInHand: [], possibleCardsInDiscardPile: [], possibleHexes: [] }; + player.target = { choosing: false, chosen: null, numChoosing: 0, possibleCardsInHand: [], possibleCardsInDiscardPile: [], possibleHexes: [] }; } export function deselect(state: State, playerColor: w.PlayerColor = state.currentTurn): State { @@ -232,7 +232,7 @@ export function activateObject(state: State, abilityIdx: number, selectedHexId: // Target still needs to be selected, so roll back playing the card (and return old state). currentPlayer(state).target = player.target; currentPlayer(state).status = { - message: `Choose a target for ${object.card.name}'s ${ability.text} ability.`, + message: `Choose ${player.target.numChoosing > 1 ? `${player.target.numChoosing} targets` : 'a target'} for ${object.card.name}'s ${ability.text} ability.`, type: 'text' }; diff --git a/src/common/reducers/handlers/game/cards.ts b/src/common/reducers/handlers/game/cards.ts index cef1994c..ef6bac5a 100644 --- a/src/common/reducers/handlers/game/cards.ts +++ b/src/common/reducers/handlers/game/cards.ts @@ -173,7 +173,7 @@ export function placeCard(state: State, cardIdx: number, tile: w.HexId): State { currentPlayer(state).target = player.target; currentPlayer(state).status = { - message: `Choose a target for ${card.name}'s ability.`, + message: `Choose ${player.target.numChoosing > 1 ? `${player.target.numChoosing} targets` : 'a target'} for ${card.name}'s ability.`, type: 'text' }; @@ -243,7 +243,7 @@ function playEvent(state: State, cardIdx: number): State { state.callbackAfterTargetSelected = ((newState: State) => playEvent(newState, cardIdx)); currentPlayer(state).selectedCard = cardIdx; currentPlayer(state).target = player.target; - currentPlayer(state).status = { message: `Choose a target for ${card.name}.`, type: 'text' }; + currentPlayer(state).status = { message: `Choose ${player.target.numChoosing > 1 ? `${player.target.numChoosing} targets` : 'a target'} for ${card.name}.`, type: 'text' }; } else if (tempState.invalid) { // Temp state is invalid (e.g. no valid target available or player unable to pay an energy cost). // So return the old state. @@ -256,7 +256,7 @@ function playEvent(state: State, cardIdx: number): State { // In that case, the player needs to "target" the board to confirm that they want to play the event. state.callbackAfterTargetSelected = ((newState: State) => playEvent(newState, cardIdx)); currentPlayer(state).selectedCard = cardIdx; - currentPlayer(state).target = { choosing: true, chosen: null, possibleCardsInHand: [], possibleCardsInDiscardPile: [], possibleHexes: allHexIds() }; + currentPlayer(state).target = { choosing: true, chosen: null, numChoosing: 0, possibleCardsInHand: [], possibleCardsInDiscardPile: [], possibleHexes: allHexIds() }; currentPlayer(state).status = { message: `Click anywhere on the board to play ${card.name}.`, type: 'text' }; } else { // Everything is good (valid state + no more targets to select), so we can return the new state! diff --git a/src/common/store/defaultGameState.ts b/src/common/store/defaultGameState.ts index b7e3ea18..8bbc1e84 100644 --- a/src/common/store/defaultGameState.ts +++ b/src/common/store/defaultGameState.ts @@ -16,6 +16,7 @@ export function defaultTarget(): w.CurrentTarget { return { choosing: false, chosen: null, + numChoosing: 0, possibleCardsInHand: [], possibleCardsInDiscardPile: [], possibleHexes: [] diff --git a/src/common/types.d.ts b/src/common/types.d.ts index 668558b2..9b0a4d6f 100644 --- a/src/common/types.d.ts +++ b/src/common/types.d.ts @@ -359,6 +359,7 @@ export interface PlayerStatus { export interface CurrentTarget { choosing: boolean chosen: Array | null + numChoosing: number possibleCardsInDiscardPile: CardId[] possibleCardsInHand: CardId[] possibleHexes: HexId[] diff --git a/src/common/util/cards.ts b/src/common/util/cards.ts index 5be7a76e..d8e79afd 100644 --- a/src/common/util/cards.ts +++ b/src/common/util/cards.ts @@ -15,7 +15,7 @@ import defaultState from '../store/defaultCollectionState'; import { CreatorStateProps } from '../containers/Creator'; import { ensureInRange, id as generateId } from './common'; -import { fetchUniversal } from './browser'; +import { fetchUniversal, onLocalhost } from './browser'; import { indexParsedSentence, lookupCurrentUser } from './firebase'; // @@ -214,7 +214,7 @@ function parse( .then((response) => response.json()) .then((json) => { callback(idx, sentence, json); - if (index && json.tokens && json.js) { + if (index && json.tokens && json.js && !onLocalhost()) { indexParsedSentence(sentence, json.tokens, json.js); } }) diff --git a/src/common/util/game.ts b/src/common/util/game.ts index 7fa1e87c..3e306abe 100644 --- a/src/common/util/game.ts +++ b/src/common/util/game.ts @@ -565,7 +565,7 @@ function endTurn(state: w.GameState): w.GameState { previousTurnPlayer.selectedCard = null; previousTurnPlayer.selectedTile = null; previousTurnPlayer.status.message = ''; - previousTurnPlayer.target = { choosing: false, chosen: null, possibleHexes: [], possibleCardsInHand: [], possibleCardsInDiscardPile: [] }; + previousTurnPlayer.target = { choosing: false, chosen: null, numChoosing: 0, possibleHexes: [], possibleCardsInHand: [], possibleCardsInDiscardPile: [] }; previousTurnPlayer.objectsOnBoard = mapValues(previousTurnPlayer.objectsOnBoard, ((obj) => ({ ...obj, attackedThisTurn: false, @@ -804,6 +804,7 @@ export function setTargetAndExecuteQueuedAction(state: w.GameState, target: w.Ca player.target = { chosen: targets, choosing: false, + numChoosing: player.target.numChoosing, possibleHexes: [], possibleCardsInHand: [], possibleCardsInDiscardPile: [] diff --git a/src/common/vocabulary/targets.ts b/src/common/vocabulary/targets.ts index c91d58e6..9164d96a 100644 --- a/src/common/vocabulary/targets.ts +++ b/src/common/vocabulary/targets.ts @@ -1,4 +1,4 @@ -import { compact, flatMap, fromPairs, isEmpty } from 'lodash'; +import { compact, flatMap, fromPairs, isEmpty, isString, uniqBy } from 'lodash'; import { shuffle } from 'seed-shuffle'; import { stringToType } from '../constants'; @@ -21,6 +21,30 @@ import { // for targets['it']: itOverride > currentObject > state.it // for targets['thisRobot']: currentObject > state.it export default function targets(state: w.GameState, currentObject: w.Object | null, itOverride: w.Object | null): Record> { + function logSelection(chosen: w.Targetable[], type: w.Collection['type']) { + if (chosen.length > 0) { + /* istanbul ignore else */ + if (['cards', 'cardsInDiscardPile', 'objects'].includes(type)) { + const cards: Record = fromPairs((chosen as Array).map((c: w.CardInGame | w.Object) => + isString(c) + ? [allObjectsOnBoard(state)[c]?.card?.name, allObjectsOnBoard(state)[c]?.card] + : g.isObject(c) + ? [c.card.name, c.card] + : [c.name, c]) + ); + const names = Object.keys(cards).map((name) => `|${name}|`); + const explanationStr = `${arrayToSentence(names)} ${chosen.length === 1 ? 'was' : 'were'} selected`; + logAction(state, null, explanationStr, cards); + } else if (type === 'players') { + const explanationStr = `${arrayToSentence((chosen as w.PlayerInGameState[]).map((p) => p.color))} ${chosen.length === 1 ? 'was' : 'were'} selected`; + logAction(state, null, explanationStr); + } else if (type === 'hexes') { + const explanationStr = `${arrayToSentence(chosen as w.HexId[])} ${chosen.length === 1 ? 'was' : 'were'} selected`; + logAction(state, null, explanationStr); + } + } + } + // Currently salient object // Note: currentObject has higher salience than state.it . // (This resolves the bug where robots' Haste ability would be triggered by other robots being played.) @@ -68,22 +92,33 @@ export default function targets(state: w.GameState, currentObject: w.Object | nu // Note: Unlike other target functions, choose() can also return a HexCollection // (if the chosen hex does not contain an object.) - choose: (collection: T): T => { + choose: (collection: T, numChoices = 1): T => { const player = currentPlayer(state); - if (player.target.chosen && player.target.chosen.length > 0) { + if (player.target.chosen && player.target.chosen.length >= numChoices) { // Return and clear chosen target. // If there's multiple targets, take the first (we treat target.chosen as a queue). - const [target, ...otherTargets] = player.target.chosen; - player.target.chosen = otherTargets; + //const [target, ...otherTargets] = player.target.chosen; + const chosenTargets = player.target.chosen.slice(0, numChoices); + player.target.chosen = player.target.chosen.slice(numChoices); + const target = chosenTargets[0]; // select the first target for type detection + + logSelection(chosenTargets, collection.type); + + // enforce that targets are distinct (if numChoices > 1) + if (uniqBy(chosenTargets, (t) => isString(t) ? t : t.id).length < chosenTargets.length) { + alert(`You must choose ${numChoices} unique targets!`); + state.invalid = true; + return { type: collection.type, entries: [] } as w.Collection as T; + } if (g.isCardInGame(target)) { - state.it = target; // "it" stores most recently chosen salient object for lookup. + state.it = target; // "it" stores most recently chosen salient object for lookup (arbitrarily choosing the first one if there's a group of >1 target) if (player.hand.map((card) => card.id).includes(target.id)) { - return { type: 'cards', entries: [target] } as w.CardInHandCollection as T; + return { type: 'cards', entries: chosenTargets } as w.CardInHandCollection as T; } else if (player.discardPile.map((card) => card.id).includes(target.id)) { - return { type: 'cardsInDiscardPile', entries: [target] } as w.CardInDiscardPileCollection as T; + return { type: 'cardsInDiscardPile', entries: chosenTargets } as w.CardInDiscardPileCollection as T; } else { /* istanbul ignore next: this case should never be hit */ throw new Error(`Card chosen does not exist in player's hand or discard pile!: ${target}`); @@ -91,10 +126,10 @@ export default function targets(state: w.GameState, currentObject: w.Object | nu } else { // Return objects if possible or hexes if not. if (collection.type === 'objects' && allObjectsOnBoard(state)[target]) { - state.it = allObjectsOnBoard(state)[target]; // "it" stores most recently chosen salient object for lookup. - return { type: 'objects', entries: [allObjectsOnBoard(state)[target]] } as w.ObjectCollection as T; + state.it = allObjectsOnBoard(state)[target]; // "it" stores most recently chosen salient object for lookup (arbitrarily choosing the first one if there's a group of >1 target) + return { type: 'objects', entries: chosenTargets.map((t) => allObjectsOnBoard(state)[t as w.HexId]) } as w.ObjectCollection as T; } else { - return { type: 'hexes', entries: [target] } as w.HexCollection as T; + return { type: 'hexes', entries: chosenTargets } as w.HexCollection as T; } } } else { @@ -106,6 +141,7 @@ export default function targets(state: w.GameState, currentObject: w.Object | nu player.target = { ...player.target, choosing: true, + numChoosing: numChoices, possibleCardsInHand: [], possibleCardsInDiscardPile: [], possibleHexes: [] @@ -200,26 +236,7 @@ export default function targets(state: w.GameState, currentObject: w.Object | nu random: (num: number, collection: T): T => { const chosen: w.Targetable[] = shuffle(collection.entries, state.rng()).slice(0, num); - - // Log the random selection. - if (chosen.length > 0) { - /* istanbul ignore else */ - if (['cards', 'cardsInDiscardPile', 'objects'].includes(collection.type)) { - const cards: Record = fromPairs((chosen as Array).map((c: w.CardInGame | w.Object) => - g.isObject(c) ? [c.card.name, c.card] : [c.name, c]) - ); - const names = Object.keys(cards).map((name) => `|${name}|`); - const explanationStr = `${arrayToSentence(names)} ${chosen.length === 1 ? 'was' : 'were'} selected`; - logAction(state, null, explanationStr, cards); - } else if (collection.type === 'players') { - const explanationStr = `${arrayToSentence((chosen as w.PlayerInGameState[]).map((p) => p.color))} ${chosen.length === 1 ? 'was' : 'were'} selected`; - logAction(state, null, explanationStr); - } else if (collection.type === 'hexes') { - const explanationStr = `${arrayToSentence(chosen as w.HexId[])} ${chosen.length === 1 ? 'was' : 'were'} selected`; - logAction(state, null, explanationStr); - } - } - + logSelection(chosen, collection.type); return { type: collection.type, entries: chosen } as w.Collection as T; }, From 96fe34dcaeab15264b83052264d8652fdd3ec0e2 Mon Sep 17 00:00:00 2001 From: Alex Nisnevich Date: Fri, 12 May 2023 02:18:11 -0700 Subject: [PATCH 2/4] minor --- src/common/vocabulary/targets.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/common/vocabulary/targets.ts b/src/common/vocabulary/targets.ts index 9164d96a..71fa60b7 100644 --- a/src/common/vocabulary/targets.ts +++ b/src/common/vocabulary/targets.ts @@ -107,6 +107,7 @@ export default function targets(state: w.GameState, currentObject: w.Object | nu logSelection(chosenTargets, collection.type); // enforce that targets are distinct (if numChoices > 1) + /** istanbul ignore next */ if (uniqBy(chosenTargets, (t) => isString(t) ? t : t.id).length < chosenTargets.length) { alert(`You must choose ${numChoices} unique targets!`); state.invalid = true; From 037fbf19671aada5f61fd7fa638255760079af79 Mon Sep 17 00:00:00 2001 From: Alex Nisnevich Date: Fri, 12 May 2023 02:18:51 -0700 Subject: [PATCH 3/4] minor --- src/common/vocabulary/targets.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/common/vocabulary/targets.ts b/src/common/vocabulary/targets.ts index 71fa60b7..fb7c5c7a 100644 --- a/src/common/vocabulary/targets.ts +++ b/src/common/vocabulary/targets.ts @@ -98,8 +98,7 @@ export default function targets(state: w.GameState, currentObject: w.Object | nu if (player.target.chosen && player.target.chosen.length >= numChoices) { // Return and clear chosen target. - // If there's multiple targets, take the first (we treat target.chosen as a queue). - //const [target, ...otherTargets] = player.target.chosen; + // If there's multiple targets, take the first `numChoices` of them (we treat target.chosen as a queue). const chosenTargets = player.target.chosen.slice(0, numChoices); player.target.chosen = player.target.chosen.slice(numChoices); const target = chosenTargets[0]; // select the first target for type detection @@ -107,7 +106,7 @@ export default function targets(state: w.GameState, currentObject: w.Object | nu logSelection(chosenTargets, collection.type); // enforce that targets are distinct (if numChoices > 1) - /** istanbul ignore next */ + /** istanbul ignore next: this would be hard to unit-test */ if (uniqBy(chosenTargets, (t) => isString(t) ? t : t.id).length < chosenTargets.length) { alert(`You must choose ${numChoices} unique targets!`); state.invalid = true; From 4ac367eb5b5cad817954cd6385f07717cfd63682 Mon Sep 17 00:00:00 2001 From: Alex Nisnevich Date: Fri, 12 May 2023 02:26:55 -0700 Subject: [PATCH 4/4] minor --- src/common/reducers/handlers/game/board.ts | 3 ++- src/common/reducers/handlers/game/cards.ts | 5 +++-- src/common/vocabulary/targets.ts | 9 +++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/common/reducers/handlers/game/board.ts b/src/common/reducers/handlers/game/board.ts index 8ca46365..c3ba0c0b 100644 --- a/src/common/reducers/handlers/game/board.ts +++ b/src/common/reducers/handlers/game/board.ts @@ -3,6 +3,7 @@ import { cloneDeep } from 'lodash'; import HexUtils from '../../../components/hexgrid/HexUtils'; import { stringToType } from '../../../constants'; import * as w from '../../../types'; +import { inBrowser, inTest } from '../../../util/browser'; import { allObjectsOnBoard, applyAbilities, canActivate, checkVictoryConditions, currentPlayer, dealDamageToObjectAtHex, executeCmd, getAttribute, hasEffect, logAction, @@ -221,7 +222,7 @@ export function activateObject(state: State, abilityIdx: number, selectedHexId: } catch (error) { // TODO better error handling: throw a custom Error object that we handle in the game reducer? console.error(error); - if (state.player === state.currentTurn) { + if (state.player === state.currentTurn && (inBrowser() || inTest())) { // Show an alert only if it's the active player's turn (i.e. it's you and not your opponent who caused the error) alert(`Oops!\n\n${error}`); } diff --git a/src/common/reducers/handlers/game/cards.ts b/src/common/reducers/handlers/game/cards.ts index ef6bac5a..f9bdad67 100644 --- a/src/common/reducers/handlers/game/cards.ts +++ b/src/common/reducers/handlers/game/cards.ts @@ -8,6 +8,7 @@ import { bluePlayerState, orangePlayerState } from '../../../store/defaultGameSt import * as w from '../../../types'; import { assertCardVisible, quoteKeywords, splitSentences } from '../../../util/cards'; import { id, nextSeed } from '../../../util/common'; +import { inBrowser, inTest } from '../../../util/browser'; import { allHexIds, applyAbilities, checkVictoryConditions, currentPlayer, deleteAllDyingObjects, discardCardsFromHand, executeCmd, getCost, logAction, @@ -128,7 +129,7 @@ export function afterObjectPlayed(state: State, playedObject: w.Object): State { } catch (error) { // TODO better error handling: throw a custom Error object that we handle in the game reducer? console.error(error); - if (state.player === state.currentTurn) { + if (state.player === state.currentTurn && (inBrowser() || inTest())) { // Show an alert only if it's the active player's turn (i.e. it's you and not your opponent who caused the error) alert(`Oops!\n\n${error}`); } @@ -227,7 +228,7 @@ function playEvent(state: State, cardIdx: number): State { } catch (error) { // TODO better error handling: throw a custom Error object that we handle in the game reducer? console.error(error); - if (state.player === state.currentTurn) { + if (state.player === state.currentTurn && (inBrowser() || inTest())) { // Show an alert only if it's the active player's turn (i.e. it's you and not your opponent who caused the error) alert(`Oops!\n\n${error}`); } diff --git a/src/common/vocabulary/targets.ts b/src/common/vocabulary/targets.ts index fb7c5c7a..26da0ded 100644 --- a/src/common/vocabulary/targets.ts +++ b/src/common/vocabulary/targets.ts @@ -5,6 +5,7 @@ import { stringToType } from '../constants'; import * as g from '../guards'; import * as w from '../types'; import { arrayToSentence, id } from '../util/common'; +import { inBrowser } from '../util/browser'; import { allObjectsOnBoard, currentPlayer, getHex, logAction, logAndReturnTarget, opponent, opponentPlayer, ownerOf @@ -106,9 +107,13 @@ export default function targets(state: w.GameState, currentObject: w.Object | nu logSelection(chosenTargets, collection.type); // enforce that targets are distinct (if numChoices > 1) - /** istanbul ignore next: this would be hard to unit-test */ + /* istanbul ignore if: this would be hard to unit-test */ if (uniqBy(chosenTargets, (t) => isString(t) ? t : t.id).length < chosenTargets.length) { - alert(`You must choose ${numChoices} unique targets!`); + if (state.player === state.currentTurn && inBrowser()) { + // Show an alert only if it's the active player's turn + alert(`You must choose ${numChoices} unique targets!`); + } + state.invalid = true; return { type: collection.type, entries: [] } as w.Collection as T; }