diff --git a/package.json b/package.json index 429ae7a..1b35d2e 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,13 @@ "@fortawesome/free-regular-svg-icons": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@reduxjs/toolkit": "^2.2.7", "@sentry/cli": "^2.32.1", "@sentry/react": "^8.15.0", "fuzzy": "^0.1.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-redux": "^9.1.2", "styled-components": "^6.1.11" }, "devDependencies": { diff --git a/src/context/universe/CardUtil.ts b/src/context/universe/CardUtil.ts index e6f7afc..006cd0a 100644 --- a/src/context/universe/CardUtil.ts +++ b/src/context/universe/CardUtil.ts @@ -1,5 +1,5 @@ +import { Universe } from '../../store/universeSlice'; import { Card } from './Card'; -import { Universe } from './Universe'; export default class CardUtil { static getCardName(universe: Universe, id: Card['id']): string | undefined { diff --git a/src/context/universe/Deck.ts b/src/context/universe/Deck.ts index 5ec595d..365a85b 100644 --- a/src/context/universe/Deck.ts +++ b/src/context/universe/Deck.ts @@ -2,15 +2,15 @@ import { Card } from './Card'; import { Group } from './Group'; export interface Deck { - readonly id: string; - readonly items: readonly DeckItem[]; + id: string; + items: DeckItem[]; } export type DeckItem = | { - readonly type: 'card'; - readonly cardId: Card['id']; + type: 'card'; + cardId: Card['id']; } | { - readonly type: 'group'; - readonly groupId: Group['id']; + type: 'group'; + groupId: Group['id']; }; diff --git a/src/context/universe/Group.ts b/src/context/universe/Group.ts index 58e327c..871bca7 100644 --- a/src/context/universe/Group.ts +++ b/src/context/universe/Group.ts @@ -1,6 +1,6 @@ import { Card } from './Card'; export interface Group { - readonly id: string; - readonly cardIds: ReadonlySet; + id: string; + cardIds: Set; } diff --git a/src/context/universe/Universe.ts b/src/context/universe/Universe.ts index e534316..692b3ac 100644 --- a/src/context/universe/Universe.ts +++ b/src/context/universe/Universe.ts @@ -2,6 +2,7 @@ import { Card } from './Card'; import { Deck } from './Deck'; import { Group } from './Group'; +/** @deprecated moving to redux */ export interface Universe { readonly decks: readonly Deck[]; readonly cards: readonly Card[]; diff --git a/src/store/cardReducers.ts b/src/store/cardReducers.ts new file mode 100644 index 0000000..dfd2f95 --- /dev/null +++ b/src/store/cardReducers.ts @@ -0,0 +1,93 @@ +import { PayloadAction } from '@reduxjs/toolkit'; +import { Deck, DeckItem } from '../context/universe/Deck'; +import { Universe } from './universeSlice'; + +export function createCards( + state: Universe, + { + payload, + }: PayloadAction<{ + targetDeckId: Deck['id']; + targetIndex: number; + names: string[]; + }>, +): void { + const newCards = payload.names.map((name) => ({ + id: crypto.randomUUID(), + name, + })); + + const deck = state.decks.find((deck) => deck.id === payload.targetDeckId); + + if (!deck) + throw new Error( + `Target deck ${payload.targetDeckId} not found when creating cards`, + ); + + deck.items.splice( + payload.targetIndex, + 0, + ...newCards.map( + (card): DeckItem => ({ + type: 'card', + cardId: card.id, + }), + ), + ); + + state.cards.push(...newCards); +} + +export function destroyCards( + state: Universe, + { + payload, + }: PayloadAction<{ + cardIds: string[]; + }>, +): void { + const cardIds = new Set(payload.cardIds); + + for (const id of cardIds) { + const card = state.cards.find((card) => card.id === id); + + if (!card) + throw new Error(`Card ${id} not found when destroying cards`); + + cardIds.add(id); + } + + // Remove all the cards from the global card list + state.cards = state.cards.filter((card) => !cardIds.has(card.id)); + + // Remove the cards from the groups. If removed from a group, instances of that group need to be removed from decks + const groupItemsRemovedById: Record = {}; + const emptyGroupIds = new Set(); + for (const group of state.groups) { + const toRemove = group.cardIds.intersection(cardIds); + + if (toRemove.size > 0) { + group.cardIds = group.cardIds.difference(toRemove); + groupItemsRemovedById[group.id] = toRemove.size; + if (group.cardIds.size === 0) { + emptyGroupIds.add(group.id); + } + } + } + state.groups = state.groups.filter((group) => !emptyGroupIds.has(group.id)); + + // Remove the cards from the decks, and remove instances of groups that have also had cards removed + for (const deck of state.decks) { + deck.items = deck.items.filter((c) => { + if (c.type === 'card' && cardIds.has(c.cardId)) return false; + if (c.type === 'group') { + if (groupItemsRemovedById[c.groupId] > 0) { + groupItemsRemovedById[c.groupId]--; + return false; + } + } + + return true; + }); + } +} diff --git a/src/store/deckReducers.ts b/src/store/deckReducers.ts new file mode 100644 index 0000000..6b59b15 --- /dev/null +++ b/src/store/deckReducers.ts @@ -0,0 +1,145 @@ +import { PayloadAction } from '@reduxjs/toolkit'; +import { Card } from '../context/universe/Card'; +import CardUtil from '../context/universe/CardUtil'; +import { Deck, DeckItem } from '../context/universe/Deck'; +import { Group } from '../context/universe/Group'; +import { Universe } from './universeSlice'; + +export function createDeck( + state: Universe, + { + payload: { id }, + }: PayloadAction<{ + id: Deck['id']; + }>, +): void { + if (state.decks.some((deck) => deck.id === id)) { + throw new Error(`Deck ${id} already exists`); + } + + state.decks.push({ + id, + items: [], + }); +} + +export function moveCard( + state: Universe, + { + payload: { fromDeckId, fromIndex, toDeckId, toIndex, count }, + }: PayloadAction<{ + fromDeckId: Deck['id']; + fromIndex: number; + toDeckId: Deck['id']; + toIndex: number; + count: number; + }>, +): void { + const fromDeck = state.decks.find((deck) => deck.id === fromDeckId); + + if (!fromDeck) { + throw new Error(`Deck ${fromDeckId} not found when moving card`); + } + + const toDeck = state.decks.find((deck) => deck.id === toDeckId); + + if (!toDeck) { + throw new Error(`Deck ${toDeckId} not found when moving card`); + } + + const cards = fromDeck.items.splice(fromIndex, count); + + toDeck.items.splice(toIndex, 0, ...cards); +} + +export function shuffleDeck( + state: Universe, + { + payload: { deckId }, + }: PayloadAction<{ + deckId: Deck['id']; + }>, +): void { + const deck = state.decks.find((deck) => deck.id === deckId); + + if (!deck) { + throw new Error(`Deck ${deckId} not found when shuffling`); + } + + const uniqueCardNames = new Set( + deck.items.flatMap((item) => { + if (item.type === 'card') + return CardUtil.getCardName(state, item.cardId); + + const group = state.groups.find( + (group) => group.id === item.groupId, + )!; + return Array.from(group.cardIds).map((cardId) => + CardUtil.getCardName(state, cardId), + ); + }), + ); + + if (uniqueCardNames.size <= 0) { + return; + } + + const numberOfItemsFromEachGroup: Record = {}; + for (const item of deck.items) { + if (item.type === 'group') { + numberOfItemsFromEachGroup[item.groupId] = + (numberOfItemsFromEachGroup[item.groupId] ?? 0) + 1; + } + } + + const allGroupsInThisDeckAreFullyInThisDeck = Array.from( + Object.entries(numberOfItemsFromEachGroup), + ).every(([groupId, count]) => { + const group = state.groups.find((group) => group.id === groupId); + + if (!group) return false; + + if (count === group.cardIds.size) return true; + + return false; + }); + + if (!allGroupsInThisDeckAreFullyInThisDeck) { + throw new Error( + 'Cannot shuffle deck with incomplete groups. Some items in this deck come from shuffle groups that have cards elsewhere, entanglement is not supported', + ); + } + + const groupsToDelete = Object.keys(numberOfItemsFromEachGroup); + + const cardsInNewGroup: Card['id'][] = []; + + for (const item of deck.items) { + if (item.type === 'card') { + cardsInNewGroup.push(item.cardId); + } + } + + for (const groupId of groupsToDelete) { + const group = state.groups.find((group) => group.id === groupId)!; + + cardsInNewGroup.push(...group.cardIds); + } + + const newGroup: Group = { + id: crypto.randomUUID(), + cardIds: new Set(cardsInNewGroup), + }; + + state.groups = [ + ...state.groups.filter((group) => !groupsToDelete.includes(group.id)), + newGroup, + ]; + + deck.items = cardsInNewGroup.map( + (): DeckItem => ({ + type: 'group', + groupId: newGroup.id, + }), + ); +} diff --git a/src/store/groupReducers.ts b/src/store/groupReducers.ts new file mode 100644 index 0000000..6918ce3 --- /dev/null +++ b/src/store/groupReducers.ts @@ -0,0 +1,114 @@ +import { PayloadAction } from '@reduxjs/toolkit'; +import { Card } from '../context/universe/Card'; +import CardUtil from '../context/universe/CardUtil'; +import { Deck } from '../context/universe/Deck'; +import { Group } from '../context/universe/Group'; +import { Universe } from './universeSlice'; + +export function revealCard( + state: Universe, + { + payload, + }: PayloadAction<{ + deckId: Deck['id']; + index: number; + as: Card['id']; + }>, +): void { + const deck = state.decks.find((deck) => deck.id === payload.deckId); + + if (!deck) { + throw new Error(`Deck ${payload.deckId} not found when revealing card`); + } + + const item = deck.items.at(payload.index); + + if (!item) + throw new Error(`Item ${payload.index} not found when revealing card`); + + if (item.type !== 'group') + throw new Error( + `Item ${payload.index} in ${payload.deckId} is already revealed`, + ); + + const group = state.groups.find((group) => group.id === item.groupId); + + if (!group) throw new Error(`Trying to reveal card in non-existent group`); + + const asCard = payload.as; + + const asCardId = Array.from(group.cardIds).find( + (cid) => CardUtil.getCardName(state, cid) === asCard, + ); + + if (asCardId === undefined) + throw new Error( + `Card ${asCard} is not possibly in position ${payload.index} of deck ${payload.deckId}`, + ); + + group.cardIds.delete(asCardId); + + revealSingletonGroups(state); +} + +function revealSingletonGroups(state: Universe) { + const reducableGroupIdCardIdsMap: Record = {}; + + for (const group of state.groups) { + const cardNames = new Set( + Array.from(group.cardIds).map((cid) => + CardUtil.getCardName(state, cid), + ), + ); + const reducable = cardNames.size <= 1; + + if (reducable) { + reducableGroupIdCardIdsMap[group.id] = Array.from(group.cardIds); + } + } + + if (Object.keys(reducableGroupIdCardIdsMap).length === 0) return state; + + for (const deck of state.decks) { + const irreducible = deck.items.every((item) => { + switch (item.type) { + case 'card': + return true; + + case 'group': { + return !reducableGroupIdCardIdsMap[item.groupId]; + } + + default: { + const _exhaustiveCheck: never = item; + return true; + } + } + }); + + if (irreducible) continue; + + for (let i = 0; i < deck.items.length; i++) { + const item = deck.items[i]; + + if (item.type !== 'group') { + continue; + } + + const groupCardIds = reducableGroupIdCardIdsMap[item.groupId]; + + if (!groupCardIds) continue; + + const cardId = groupCardIds.pop()!; + + deck.items[i] = { + type: 'card', + cardId, + }; + } + } + + state.groups = state.groups.filter( + (group) => !reducableGroupIdCardIdsMap[group.id], + ); +} diff --git a/src/store/hooks.ts b/src/store/hooks.ts new file mode 100644 index 0000000..7988133 --- /dev/null +++ b/src/store/hooks.ts @@ -0,0 +1,5 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from './store'; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/store/saveStateReducers.ts b/src/store/saveStateReducers.ts new file mode 100644 index 0000000..8383ac5 --- /dev/null +++ b/src/store/saveStateReducers.ts @@ -0,0 +1,13 @@ +import { Action, PayloadAction } from '@reduxjs/toolkit'; +import { Universe } from './universeSlice'; + +export function reset(_state: Universe, _action: Action): Universe { + return Universe.empty(); +} + +export function load( + _state: Universe, + { payload: { universe } }: PayloadAction<{ universe: Universe }>, +): Universe { + return structuredClone(universe); +} diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..7d584e7 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,16 @@ +import { configureStore } from '@reduxjs/toolkit'; +import universeSlice from './universeSlice'; + +export const store = configureStore({ + reducer: { + universeSlice, + }, + middleware: (getDefaultMiddleware) => { + return getDefaultMiddleware().concat((store) => (next) => (action) => { + return next(action); + }); + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/store/universeSlice.ts b/src/store/universeSlice.ts new file mode 100644 index 0000000..aa925be --- /dev/null +++ b/src/store/universeSlice.ts @@ -0,0 +1,43 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { Card } from '../context/universe/Card'; +import { Deck } from '../context/universe/Deck'; +import { Group } from '../context/universe/Group'; +import { createCards, destroyCards } from './cardReducers'; +import { createDeck, moveCard, shuffleDeck } from './deckReducers'; +import { revealCard } from './groupReducers'; +import { load, reset } from './saveStateReducers'; + +export interface Universe { + decks: Deck[]; + cards: Card[]; + groups: Group[]; +} + +export namespace Universe { + export function empty(): Universe { + return { + decks: [], + cards: [], + groups: [], + }; + } +} + +export const universeSlice = createSlice({ + name: 'universe', + initialState: Universe.empty, + reducers: { + createCards, + destroyCards, + createDeck, + moveCard, + shuffleDeck, + revealCard, + reset, + load, + }, +}); + +export const actions = universeSlice.actions; + +export default universeSlice.reducer; diff --git a/tsconfig.app.json b/tsconfig.app.json index fa598b1..292ad76 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -2,9 +2,9 @@ "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", + "target": "ESNext", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, diff --git a/yarn.lock b/yarn.lock index de41fe5..23fca56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -513,6 +513,16 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== +"@reduxjs/toolkit@^2.2.7": + version "2.2.7" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.2.7.tgz#199e3d10ccb39267cb5aee92c0262fd9da7fdfb2" + integrity sha512-faI3cZbSdFb8yv9dhDTmGwclW0vk0z5o1cia+kf7gCbaCwHI5e+7tP57mJUv22pNcNbeA62GSrPpfrUfdXcQ6g== + dependencies: + immer "^10.0.3" + redux "^5.0.1" + redux-thunk "^3.1.0" + reselect "^5.1.0" + "@rollup/rollup-android-arm-eabi@4.18.1": version "4.18.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.1.tgz#f0da481244b7d9ea15296b35f7fe39cd81157396" @@ -792,6 +802,11 @@ resolved "https://registry.yarnpkg.com/@types/stylis/-/stylis-4.2.5.tgz#1daa6456f40959d06157698a653a9ab0a70281df" integrity sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw== +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + "@typescript-eslint/eslint-plugin@7.16.1", "@typescript-eslint/eslint-plugin@^7.16.1": version "7.16.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.1.tgz#f5f5da52db674b1f2cdb9d5f3644e5b2ec750465" @@ -1417,6 +1432,11 @@ ignore@^5.2.0, ignore@^5.3.1: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== +immer@^10.0.3: + version "10.1.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -1734,6 +1754,14 @@ react-is@^16.13.1, react-is@^16.7.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-redux@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.1.2.tgz#deba38c64c3403e9abd0c3fbeab69ffd9d8a7e4b" + integrity sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w== + dependencies: + "@types/use-sync-external-store" "^0.0.3" + use-sync-external-store "^1.0.0" + react-refresh@^0.14.2: version "0.14.2" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" @@ -1746,6 +1774,21 @@ react@^18.3.1: dependencies: loose-envify "^1.1.0" +redux-thunk@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" + integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== + +redux@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + +reselect@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" + integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -1959,6 +2002,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +use-sync-external-store@^1.0.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" + integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== + vite@^5.3.3: version "5.3.3" resolved "https://registry.yarnpkg.com/vite/-/vite-5.3.3.tgz#5265b1f0a825b3b6564c2d07524777c83e3c04c2"