From 140eb89be0bd013b54dafdd3863794de5c11d5dc Mon Sep 17 00:00:00 2001 From: Daithi Hearn Date: Tue, 4 Jul 2023 00:10:41 +0200 Subject: [PATCH] Adding support for auto buying of cards --- package.json | 2 +- public/manifest.json | 2 +- src/caches/GameSlice.ts | 5 ++ src/caches/MyCardsSlice.ts | 7 +++ src/caches/SettingsSlice.ts | 20 +++++++ src/caches/caches.ts | 2 + src/components/Game/Actions/Buying.tsx | 56 +++++++++---------- src/components/Game/Actions/SelectSuit.tsx | 21 +++---- .../Game/Actions/ThrowCardsWarningModal.tsx | 12 ++-- src/components/Game/AutoActionManager.tsx | 38 ++++++++++++- src/components/Game/MyCards.tsx | 26 +++++---- src/model/PlayerSettings.ts | 3 + src/pages/Home/Home.tsx | 11 ++++ src/services/SettingsService.ts | 34 +++++++++++ src/utils/GameUtils.ts | 53 +++++++++++++++--- 15 files changed, 222 insertions(+), 70 deletions(-) create mode 100644 src/caches/SettingsSlice.ts create mode 100644 src/model/PlayerSettings.ts create mode 100644 src/services/SettingsService.ts diff --git a/package.json b/package.json index ea54332..d018bc2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "7.0.8", + "version": "7.1.0", "description": "React frontend for the Cards 110", "author": "Daithi Hearn", "license": "MIT", diff --git a/public/manifest.json b/public/manifest.json index bae4efe..e591ebd 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "short_name": "Cards 110", "name": "Cards 110", - "version": "7.0.8", + "version": "7.1.0", "icons": [ { "src": "./assets/favicon.png", diff --git a/src/caches/GameSlice.ts b/src/caches/GameSlice.ts index 47795bb..22f53b8 100644 --- a/src/caches/GameSlice.ts +++ b/src/caches/GameSlice.ts @@ -4,6 +4,7 @@ import { GameState, GameStatus, PlayedCard } from "model/Game" import { Player } from "model/Player" import { RoundStatus } from "model/Round" import { RootState } from "./caches" +import { get } from "http" const initialState: GameState = { iamSpectator: true, @@ -45,6 +46,10 @@ export const { export const getGame = (state: RootState) => state.game export const getGamePlayers = createSelector(getGame, game => game.players) +export const getNumPlayers = createSelector( + getGamePlayers, + players => players.length, +) export const getMe = createSelector(getGame, game => game.me) export const getRound = createSelector(getGame, game => game.round) diff --git a/src/caches/MyCardsSlice.ts b/src/caches/MyCardsSlice.ts index c80a80f..502fcc6 100644 --- a/src/caches/MyCardsSlice.ts +++ b/src/caches/MyCardsSlice.ts @@ -37,6 +37,12 @@ export const myCardsSlice = createSlice({ if (c.name === action.payload.name) c.selected = true }) }, + selectCards: (state, action: PayloadAction) => { + state.cards.forEach(c => { + if (action.payload.some(a => a.name === c.name)) + c.selected = true + }) + }, toggleSelect: (state, action: PayloadAction) => state.cards.forEach(c => { if (c.name === action.payload.name) c.selected = !c.selected @@ -67,6 +73,7 @@ export const { clearSelectedCards, selectAll, selectCard, + selectCards, toggleSelect, toggleUniqueSelect, clearMyCards, diff --git a/src/caches/SettingsSlice.ts b/src/caches/SettingsSlice.ts new file mode 100644 index 0000000..b0bca7d --- /dev/null +++ b/src/caches/SettingsSlice.ts @@ -0,0 +1,20 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit" +import { RootState } from "./caches" +import { PlayerSettings } from "model/PlayerSettings" + +const initialState: PlayerSettings = { + autoBuyCards: false, +} + +export const settingsSlice = createSlice({ + name: "settings", + initialState: initialState, + reducers: { + updateSettings: (_, action: PayloadAction) => + action.payload, + }, +}) + +export const { updateSettings } = settingsSlice.actions + +export const getSettings = (state: RootState) => state.settings diff --git a/src/caches/caches.ts b/src/caches/caches.ts index 278a56b..1601d24 100644 --- a/src/caches/caches.ts +++ b/src/caches/caches.ts @@ -13,6 +13,7 @@ import { myGamesSlice } from "./MyGamesSlice" import { myCardsSlice } from "./MyCardsSlice" import { autoPlaySlice } from "./AutoPlaySlice" import { playerProfilesSlice } from "./PlayerProfilesSlice" +import { settingsSlice } from "./SettingsSlice" const combinedReducer = combineReducers({ myProfile: myProfileSlice.reducer, @@ -21,6 +22,7 @@ const combinedReducer = combineReducers({ playerProfiles: playerProfilesSlice.reducer, myCards: myCardsSlice.reducer, autoPlay: autoPlaySlice.reducer, + settings: settingsSlice.reducer, }) export type RootState = ReturnType diff --git a/src/components/Game/Actions/Buying.tsx b/src/components/Game/Actions/Buying.tsx index 676a7f1..f3b4a61 100644 --- a/src/components/Game/Actions/Buying.tsx +++ b/src/components/Game/Actions/Buying.tsx @@ -10,16 +10,18 @@ import { } from "caches/MyCardsSlice" import { getGameId, + getNumPlayers, getIamGoer, getIHavePlayed, getIsMyGo, getSuit, } from "caches/GameSlice" -import { riskOfMistakeBuyingCards } from "utils/GameUtils" +import { pickBestCards, riskOfMistakeBuyingCards } from "utils/GameUtils" import ThrowCardsWarningModal from "./ThrowCardsWarningModal" import { SelectableCard } from "model/Cards" import parseError from "utils/ErrorUtils" import { Button } from "@mui/material" +import { getSettings } from "caches/SettingsSlice" const WaitingForRoundToStart = () => ( - ) : ( - - )} + { [gameId, selectedCards], ) - const selectFromDummyCallback = ( - id: string, - sel: SelectableCard[], - suit?: Suit, - ) => { - if (!suit) throw Error("Must provide a suit") - dispatch(GameService.chooseFromDummy(id, sel, suit)).catch((e: Error) => - enqueueSnackbar(parseError(e), { variant: "error" }), - ) - } + const selectFromDummyCallback = useCallback( + (sel: SelectableCard[], suit?: Suit) => { + if (!gameId) throw Error("Must be in a game") + if (!suit) throw Error("Must provide a suit") + dispatch(GameService.chooseFromDummy(gameId, sel, suit)).catch( + (e: Error) => + enqueueSnackbar(parseError(e), { variant: "error" }), + ) + }, + [gameId], + ) const hideCancelSelectFromDummyDialog = useCallback(() => { setSelectedSuit(undefined) diff --git a/src/components/Game/Actions/ThrowCardsWarningModal.tsx b/src/components/Game/Actions/ThrowCardsWarningModal.tsx index 3e67b26..524fe70 100644 --- a/src/components/Game/Actions/ThrowCardsWarningModal.tsx +++ b/src/components/Game/Actions/ThrowCardsWarningModal.tsx @@ -14,12 +14,12 @@ import { useAppSelector } from "caches/hooks" import { getMyCardsWithoutBlanks, getSelectedCards } from "caches/MyCardsSlice" import { SelectableCard } from "model/Cards" import { Suit } from "model/Suit" -import { removeAllFromHand } from "utils/GameUtils" +import { getTrumpCards, removeAllFromHand } from "utils/GameUtils" interface ModalOpts { modalVisible: boolean cancelCallback: () => void - continueCallback: (id: string, sel: SelectableCard[], suit?: Suit) => void + continueCallback: (sel: SelectableCard[], suit?: Suit) => void suit: Suit } @@ -29,19 +29,17 @@ const ThrowCardsWarningModal: React.FC = ({ continueCallback, suit, }) => { - const gameId = useAppSelector(getGameId) const myCards = useAppSelector(getMyCardsWithoutBlanks) const selectedCards = useAppSelector(getSelectedCards) const cardsToBeThrown = useMemo( - () => removeAllFromHand(selectedCards, myCards), + () => getTrumpCards(removeAllFromHand(selectedCards, myCards), suit), [myCards, selectedCards], ) const callContinue = useCallback(() => { - if (!gameId) throw Error("GameId not set") - continueCallback(gameId, selectedCards, suit) - }, [gameId, selectedCards]) + continueCallback(selectedCards, suit) + }, [selectedCards]) return ( cancelCallback()} open={modalVisible}> diff --git a/src/components/Game/AutoActionManager.tsx b/src/components/Game/AutoActionManager.tsx index 5021339..af30b6e 100644 --- a/src/components/Game/AutoActionManager.tsx +++ b/src/components/Game/AutoActionManager.tsx @@ -1,27 +1,38 @@ -import { useEffect } from "react" +import { useCallback, useEffect } from "react" import GameService from "services/GameService" import { useAppDispatch, useAppSelector } from "caches/hooks" import { getCards, getGameId, + getIamGoer, getIsInBunker, getIsMyGo, + getNumPlayers, getRound, + getSuit, } from "caches/GameSlice" import { RoundStatus } from "model/Round" import { getAutoPlayCard } from "caches/AutoPlaySlice" -import { bestCardLead, getWorstCard } from "utils/GameUtils" +import { bestCardLead, getWorstCard, pickBestCards } from "utils/GameUtils" import { useSnackbar } from "notistack" import parseError from "utils/ErrorUtils" +import { getSettings } from "caches/SettingsSlice" +import { SelectableCard } from "model/Cards" +import { getMyCardsWithoutBlanks } from "caches/MyCardsSlice" const AutoActionManager = () => { const dispatch = useAppDispatch() const { enqueueSnackbar } = useSnackbar() + const settings = useAppSelector(getSettings) + const numPlayers = useAppSelector(getNumPlayers) + const suit = useAppSelector(getSuit) + const myCards = useAppSelector(getMyCardsWithoutBlanks) const gameId = useAppSelector(getGameId) const round = useAppSelector(getRound) const cards = useAppSelector(getCards) + const iamGoer = useAppSelector(getIamGoer) const autoPlayCard = useAppSelector(getAutoPlayCard) @@ -35,6 +46,16 @@ const AutoActionManager = () => { }) }) + const buyCards = useCallback( + (sel: SelectableCard[]) => { + if (!gameId) return + dispatch(GameService.buyCards(gameId, sel)).catch((e: Error) => + enqueueSnackbar(parseError(e), { variant: "error" }), + ) + }, + [gameId], + ) + const call = (id: string, callAmount: number) => dispatch(GameService.call(id, callAmount)).catch(console.error) @@ -60,6 +81,19 @@ const AutoActionManager = () => { } }, [gameId, round, isMyGo, cards, autoPlayCard]) + // Auto buy cards + useEffect(() => { + if ( + settings.autoBuyCards && + suit && + isMyGo && + round?.status === RoundStatus.BUYING && + !iamGoer + ) { + buyCards(pickBestCards(myCards, suit, numPlayers)) + } + }, [settings, isMyGo, iamGoer, round, myCards, suit, numPlayers]) + return null } diff --git a/src/components/Game/MyCards.tsx b/src/components/Game/MyCards.tsx index 437f490..20b2eb0 100644 --- a/src/components/Game/MyCards.tsx +++ b/src/components/Game/MyCards.tsx @@ -7,13 +7,19 @@ import { } from "react-beautiful-dnd" import { BLANK_CARD, SelectableCard } from "model/Cards" import { RoundStatus } from "model/Round" -import { getIamGoer, getIsRoundCalled, getRound } from "caches/GameSlice" +import { + getGameId, + getIamGoer, + getIsRoundCalled, + getRound, + getNumPlayers, +} from "caches/GameSlice" import { useAppDispatch, useAppSelector } from "caches/hooks" import { clearSelectedCards, getMyCards, replaceMyCards, - selectCard, + selectCards, toggleSelect, toggleUniqueSelect, } from "caches/MyCardsSlice" @@ -23,6 +29,7 @@ import { clearAutoPlay, } from "caches/AutoPlaySlice" import { CardContent, CardMedia, useTheme } from "@mui/material" +import { pickBestCards } from "utils/GameUtils" const EMPTY_HAND = [ { ...BLANK_CARD, selected: false }, @@ -44,6 +51,8 @@ const MyCards: React.FC = () => { const theme = useTheme() const dispatch = useAppDispatch() const round = useAppSelector(getRound) + const gameId = useAppSelector(getGameId) + const numPlayers = useAppSelector(getNumPlayers) const isRoundCalled = useAppSelector(getIsRoundCalled) const myCards = useAppSelector(getMyCards) const autoPlayCard = useAppSelector(getAutoPlayCard) @@ -58,18 +67,11 @@ const MyCards: React.FC = () => { round.suit ) { // Auto select all cards of a specific suit when the round status is BUYING + const bestCards = pickBestCards(myCards, round.suit, numPlayers) - const cardsOfSuit = myCards.filter( - card => - card.suit === round.suit || - card.name === "JOKER" || - card.name === "ACE_HEARTS", - ) - cardsOfSuit.forEach(card => { - dispatch(selectCard(card)) - }) + dispatch(selectCards(bestCards)) } - }, [round, myCards, prevRoundStatus, iamGoer]) + }, [round, numPlayers, gameId, myCards, prevRoundStatus, iamGoer]) const cardsSelectable = useMemo( () => diff --git a/src/model/PlayerSettings.ts b/src/model/PlayerSettings.ts new file mode 100644 index 0000000..7be32b5 --- /dev/null +++ b/src/model/PlayerSettings.ts @@ -0,0 +1,3 @@ +export interface PlayerSettings { + autoBuyCards: boolean +} diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 3b705fd..1df5bc2 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -12,12 +12,19 @@ import GameService from "services/GameService" import { useSnackbar } from "notistack" import StatsService from "services/StatsService" import parseError from "utils/ErrorUtils" +import SettingsService from "services/SettingsService" const Home = () => { const dispatch = useAppDispatch() const myProfile = useAppSelector(getMyProfile) const { enqueueSnackbar } = useSnackbar() + const getSettings = async () => { + await dispatch(SettingsService.getSettings()).catch((e: Error) => + enqueueSnackbar(parseError(e), { variant: "error" }), + ) + } + const fetchData = async () => { if (myProfile.isAdmin) await dispatch(GameService.getAllPlayers()).catch((e: Error) => @@ -35,6 +42,10 @@ const Home = () => { fetchData() }, []) + useEffect(() => { + getSettings() + }) + return (
diff --git a/src/services/SettingsService.ts b/src/services/SettingsService.ts new file mode 100644 index 0000000..8db7254 --- /dev/null +++ b/src/services/SettingsService.ts @@ -0,0 +1,34 @@ +import axios from "axios" +import { AppThunk } from "caches/caches" +import { getAccessToken } from "caches/MyProfileSlice" +import { PlayerSettings } from "model/PlayerSettings" +import { getDefaultConfig } from "utils/AxiosUtils" +import { updateSettings as updateSettingsCache } from "caches/SettingsSlice" + +const getSettings = + (): AppThunk> => async (dispatch, getState) => { + const accessToken = getAccessToken(getState()) + + const response = await axios.get( + `${process.env.REACT_APP_API_URL}/api/v1/settings`, + getDefaultConfig(accessToken), + ) + dispatch(updateSettingsCache(response.data)) + return response.data + } + +const updateSettings = + (settings: PlayerSettings): AppThunk> => + async (dispatch, getState) => { + const accessToken = getAccessToken(getState()) + + await axios.put( + `${process.env.REACT_APP_API_URL}/api/v1/settings`, + settings, + getDefaultConfig(accessToken), + ) + + dispatch(updateSettingsCache(settings)) + } + +export default { getSettings, updateSettings } diff --git a/src/utils/GameUtils.ts b/src/utils/GameUtils.ts index b3207ae..26a267c 100644 --- a/src/utils/GameUtils.ts +++ b/src/utils/GameUtils.ts @@ -36,7 +36,7 @@ export const padMyHand = (cards: SelectableCard[]): SelectableCard[] => { return paddedCards } -export const processOrderedCardsAfterGameUpdate = ( +export const processOrderedCardsAfterGameUpdate = ( currentCards: SelectableCard[], updatedCardNames: string[], ): SelectableCard[] => { @@ -78,10 +78,10 @@ export const processOrderedCardsAfterGameUpdate = ( } } -export const riskOfMistakeBuyingCards = ( +export const riskOfMistakeBuyingCards = ( suit: Suit, - selectedCards: Card[], - myCards: Card[], + selectedCards: T[], + myCards: T[], ) => { const deletingCards = removeAllFromHand(selectedCards, myCards) @@ -98,9 +98,9 @@ export const riskOfMistakeBuyingCards = ( return false } -export const removeAllFromHand = ( - cardsToRemove: Card[], - originalHand: Card[], +export const removeAllFromHand = ( + cardsToRemove: T[], + originalHand: T[], ) => { let newHand = [...originalHand] const ctr = [...cardsToRemove] @@ -110,7 +110,10 @@ export const removeAllFromHand = ( return newHand } -export const removeCard = (cardToRemove: Card, orginalHand: Card[]) => { +export const removeCard = ( + cardToRemove: T, + orginalHand: T[], +) => { const newHand = [...orginalHand] // make a separate copy of the array const index = newHand.indexOf(cardToRemove) if (index !== -1) { @@ -169,3 +172,37 @@ export const getWorstCard = (cards: string[], suit: Suit) => { return myCardsRich[0] } } + +export const pickBestCards = ( + cards: T[], + suit: Suit, + numPlayers: number, +): T[] => { + // If number of players not in range 2-5 then throw an error + if (numPlayers < 2 || numPlayers > 5) { + throw new Error("Number of players must be between 2 and 5") + } + const minCardsRequired = Math.max(0, numPlayers - 3) + const bestCards = getTrumpCards(cards, suit) + + while (bestCards.length < minCardsRequired) { + // Find the card with the highest cold value that isn't already in the best cards + const remainingCards = cards.filter( + c => !bestCards.some(bc => bc.name === c.name), + ) + if (remainingCards.length === 0) break + bestCards.push( + remainingCards.sort((a, b) => b.coldValue - a.coldValue)[0], + ) + } + + return bestCards +} + +export const getTrumpCards = (cards: T[], suit: Suit): T[] => + cards.filter( + card => + card.suit === suit || + card.name === "JOKER" || + card.name === "ACE_HEARTS", + )