From a6d1f70d14aa363e27cffac0d655cedaf0990ee0 Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 06:18:58 +0000 Subject: [PATCH 01/11] fix: Build id --- .github/workflows/deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d72682a..343ef85 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,7 +32,8 @@ jobs: - name: Install dependencies run: npm install - + - name: Prebuild + run: npm run prebuild - name: Build env: VITE_GA_ID: ${{ secrets.VITE_GA_ID }} From b6b02d002e0529c2da80a8e823d95bd224aa85a1 Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:20:31 +0000 Subject: [PATCH 02/11] feat: add 8 new characters --- src/data/characters.json | 104 +++++++++ src/data/rickandmortyapi.json | 408 ++++++++++++++++++++++++++++------ 2 files changed, 438 insertions(+), 74 deletions(-) diff --git a/src/data/characters.json b/src/data/characters.json index 4268598..1664a67 100644 --- a/src/data/characters.json +++ b/src/data/characters.json @@ -155,5 +155,109 @@ "avatarId": 12, "baseMultiplier": 12, "basePower": 5 + }, + { + "id": 13, + "name": "Alien Googah", + "status": "unknown", + "species": "Alien", + "type": "", + "gender": "unknown", + "origin": "unknown", + "location": "Earth (Replacement Dimension)", + "avatarId": 13, + "baseMultiplier": 50, + "basePower": 50 + }, + { + "id": 14, + "name": "Alien Morty", + "status": "unknown", + "species": "Alien", + "type": "", + "gender": "Male", + "origin": "unknown", + "location": "Citadel of Ricks", + "avatarId": 14, + "baseMultiplier": 60, + "basePower": 120 + }, + { + "id": 15, + "name": "Alien Rick", + "status": "unknown", + "species": "Alien", + "type": "", + "gender": "Male", + "origin": "unknown", + "location": "Citadel of Ricks", + "avatarId": 15, + "baseMultiplier": 500, + "basePower": 400 + }, + { + "id": 16, + "name": "Amish Cyborg", + "status": "Dead", + "species": "Alien", + "type": "Parasite", + "gender": "Male", + "origin": "unknown", + "location": "Earth (Replacement Dimension)", + "avatarId": 16, + "baseMultiplier": 75, + "basePower": 80 + }, + { + "id": 17, + "name": "Annie", + "status": "Alive", + "species": "Human", + "type": "", + "gender": "Female", + "origin": "Earth (C-137)", + "location": "Anatomy Park", + "avatarId": 17, + "baseMultiplier": 40, + "basePower": 20 + }, + { + "id": 18, + "name": "Antenna Morty", + "status": "Alive", + "species": "Human", + "type": "Human with antennae", + "gender": "Male", + "origin": "unknown", + "location": "Citadel of Ricks", + "avatarId": 18, + "baseMultiplier": 65, + "basePower": 130 + }, + { + "id": 19, + "name": "Antenna Rick", + "status": "unknown", + "species": "Human", + "type": "Human with antennae", + "gender": "Male", + "origin": "unknown", + "location": "unknown", + "avatarId": 19, + "baseMultiplier": 450, + "basePower": 350 + }, + { + "id": 20, + "name": "Ants in my Eyes Johnson", + "status": "unknown", + "species": "Human", + "type": "Human with ants in his eyes", + "gender": "Male", + "origin": "unknown", + "location": "Interdimensional Cable", + "avatarId": 20, + "baseMultiplier": 120, + "basePower": 10 } ] diff --git a/src/data/rickandmortyapi.json b/src/data/rickandmortyapi.json index 1697098..e6a4499 100644 --- a/src/data/rickandmortyapi.json +++ b/src/data/rickandmortyapi.json @@ -2,148 +2,408 @@ "info": { "count": 826, "pages": 42, - "next": "https://rickandmortyapi.com/api/character?page=2", - "prev": null + "next": "https://rickandmortyapi.com/api/character?page=3", + "prev": "https://rickandmortyapi.com/api/character?page=1" }, "results": [ { - "id": 13, - "name": "Alien Googah", + "id": 21, + "name": "Aqua Morty", "status": "unknown", - "species": "Alien", - "type": "", - "gender": "unknown", + "species": "Humanoid", + "type": "Fish-Person", + "gender": "Male", "origin": { "name": "unknown", "url": "" }, "location": { - "name": "Earth (Replacement Dimension)", - "url": "https://rickandmortyapi.com/api/location/20" + "name": "Citadel of Ricks", + "url": "https://rickandmortyapi.com/api/location/3" }, - "image": "https://rickandmortyapi.com/api/character/avatar/13.jpeg", - "episode": ["https://rickandmortyapi.com/api/episode/31"], - "url": "https://rickandmortyapi.com/api/character/13", - "created": "2017-11-04T20:33:30.779Z" + "image": "https://rickandmortyapi.com/api/character/avatar/21.jpeg", + "episode": [ + "https://rickandmortyapi.com/api/episode/10", + "https://rickandmortyapi.com/api/episode/22" + ], + "url": "https://rickandmortyapi.com/api/character/21", + "created": "2017-11-04T22:39:48.055Z" }, { - "id": 14, - "name": "Alien Morty", + "id": 22, + "name": "Aqua Rick", "status": "unknown", - "species": "Alien", - "type": "", + "species": "Humanoid", + "type": "Fish-Person", "gender": "Male", "origin": { "name": "unknown", "url": "" }, "location": { "name": "Citadel of Ricks", "url": "https://rickandmortyapi.com/api/location/3" }, - "image": "https://rickandmortyapi.com/api/character/avatar/14.jpeg", - "episode": ["https://rickandmortyapi.com/api/episode/10"], - "url": "https://rickandmortyapi.com/api/character/14", - "created": "2017-11-04T20:51:31.373Z" + "image": "https://rickandmortyapi.com/api/character/avatar/22.jpeg", + "episode": [ + "https://rickandmortyapi.com/api/episode/10", + "https://rickandmortyapi.com/api/episode/22", + "https://rickandmortyapi.com/api/episode/28" + ], + "url": "https://rickandmortyapi.com/api/character/22", + "created": "2017-11-04T22:41:07.171Z" }, { - "id": 15, - "name": "Alien Rick", + "id": 23, + "name": "Arcade Alien", "status": "unknown", "species": "Alien", "type": "", "gender": "Male", "origin": { "name": "unknown", "url": "" }, "location": { - "name": "Citadel of Ricks", - "url": "https://rickandmortyapi.com/api/location/3" + "name": "Immortality Field Resort", + "url": "https://rickandmortyapi.com/api/location/7" }, - "image": "https://rickandmortyapi.com/api/character/avatar/15.jpeg", - "episode": ["https://rickandmortyapi.com/api/episode/10"], - "url": "https://rickandmortyapi.com/api/character/15", - "created": "2017-11-04T20:56:13.215Z" + "image": "https://rickandmortyapi.com/api/character/avatar/23.jpeg", + "episode": [ + "https://rickandmortyapi.com/api/episode/13", + "https://rickandmortyapi.com/api/episode/19", + "https://rickandmortyapi.com/api/episode/21", + "https://rickandmortyapi.com/api/episode/25", + "https://rickandmortyapi.com/api/episode/26" + ], + "url": "https://rickandmortyapi.com/api/character/23", + "created": "2017-11-05T08:43:05.095Z" }, { - "id": 16, - "name": "Amish Cyborg", - "status": "Dead", + "id": 24, + "name": "Armagheadon", + "status": "Alive", "species": "Alien", - "type": "Parasite", + "type": "Cromulon", "gender": "Male", - "origin": { "name": "unknown", "url": "" }, + "origin": { + "name": "Signus 5 Expanse", + "url": "https://rickandmortyapi.com/api/location/22" + }, "location": { - "name": "Earth (Replacement Dimension)", - "url": "https://rickandmortyapi.com/api/location/20" + "name": "Signus 5 Expanse", + "url": "https://rickandmortyapi.com/api/location/22" }, - "image": "https://rickandmortyapi.com/api/character/avatar/16.jpeg", - "episode": ["https://rickandmortyapi.com/api/episode/15"], - "url": "https://rickandmortyapi.com/api/character/16", - "created": "2017-11-04T21:12:45.235Z" + "image": "https://rickandmortyapi.com/api/character/avatar/24.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/16"], + "url": "https://rickandmortyapi.com/api/character/24", + "created": "2017-11-05T08:48:30.776Z" + }, + { + "id": 25, + "name": "Armothy", + "status": "Dead", + "species": "unknown", + "type": "Self-aware arm", + "gender": "Male", + "origin": { + "name": "Post-Apocalyptic Earth", + "url": "https://rickandmortyapi.com/api/location/8" + }, + "location": { + "name": "Post-Apocalyptic Earth", + "url": "https://rickandmortyapi.com/api/location/8" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/25.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/23"], + "url": "https://rickandmortyapi.com/api/character/25", + "created": "2017-11-05T08:54:29.343Z" }, { - "id": 17, - "name": "Annie", + "id": 26, + "name": "Arthricia", "status": "Alive", - "species": "Human", - "type": "", + "species": "Alien", + "type": "Cat-Person", "gender": "Female", "origin": { - "name": "Earth (C-137)", - "url": "https://rickandmortyapi.com/api/location/1" + "name": "Purge Planet", + "url": "https://rickandmortyapi.com/api/location/9" }, "location": { - "name": "Anatomy Park", - "url": "https://rickandmortyapi.com/api/location/5" + "name": "Purge Planet", + "url": "https://rickandmortyapi.com/api/location/9" }, - "image": "https://rickandmortyapi.com/api/character/avatar/17.jpeg", - "episode": ["https://rickandmortyapi.com/api/episode/3"], - "url": "https://rickandmortyapi.com/api/character/17", - "created": "2017-11-04T22:21:24.481Z" + "image": "https://rickandmortyapi.com/api/character/avatar/26.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/20"], + "url": "https://rickandmortyapi.com/api/character/26", + "created": "2017-11-05T08:56:46.165Z" }, { - "id": 18, - "name": "Antenna Morty", + "id": 27, + "name": "Artist Morty", "status": "Alive", "species": "Human", - "type": "Human with antennae", + "type": "", "gender": "Male", "origin": { "name": "unknown", "url": "" }, "location": { "name": "Citadel of Ricks", "url": "https://rickandmortyapi.com/api/location/3" }, - "image": "https://rickandmortyapi.com/api/character/avatar/18.jpeg", + "image": "https://rickandmortyapi.com/api/character/avatar/27.jpeg", "episode": [ "https://rickandmortyapi.com/api/episode/10", "https://rickandmortyapi.com/api/episode/28" ], - "url": "https://rickandmortyapi.com/api/character/18", - "created": "2017-11-04T22:25:29.008Z" + "url": "https://rickandmortyapi.com/api/character/27", + "created": "2017-11-05T08:59:07.457Z" }, { - "id": 19, - "name": "Antenna Rick", - "status": "unknown", + "id": 28, + "name": "Attila Starwar", + "status": "Alive", "species": "Human", - "type": "Human with antennae", + "type": "", "gender": "Male", "origin": { "name": "unknown", "url": "" }, - "location": { "name": "unknown", "url": "" }, - "image": "https://rickandmortyapi.com/api/character/avatar/19.jpeg", - "episode": ["https://rickandmortyapi.com/api/episode/10"], - "url": "https://rickandmortyapi.com/api/character/19", - "created": "2017-11-04T22:28:13.756Z" + "location": { + "name": "Interdimensional Cable", + "url": "https://rickandmortyapi.com/api/location/6" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/28.jpeg", + "episode": [ + "https://rickandmortyapi.com/api/episode/8", + "https://rickandmortyapi.com/api/episode/13", + "https://rickandmortyapi.com/api/episode/17" + ], + "url": "https://rickandmortyapi.com/api/character/28", + "created": "2017-11-05T09:02:16.595Z" }, { - "id": 20, - "name": "Ants in my Eyes Johnson", - "status": "unknown", + "id": 29, + "name": "Baby Legs", + "status": "Alive", "species": "Human", - "type": "Human with ants in his eyes", + "type": "Human with baby legs", + "gender": "Male", + "origin": { "name": "unknown", "url": "" }, + "location": { + "name": "Interdimensional Cable", + "url": "https://rickandmortyapi.com/api/location/6" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/29.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/8"], + "url": "https://rickandmortyapi.com/api/character/29", + "created": "2017-11-05T09:06:19.644Z" + }, + { + "id": 30, + "name": "Baby Poopybutthole", + "status": "Alive", + "species": "Poopybutthole", + "type": "", + "gender": "Male", + "origin": { "name": "unknown", "url": "" }, + "location": { "name": "unknown", "url": "" }, + "image": "https://rickandmortyapi.com/api/character/avatar/30.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/31"], + "url": "https://rickandmortyapi.com/api/character/30", + "created": "2017-11-05T09:13:16.483Z" + }, + { + "id": 31, + "name": "Baby Wizard", + "status": "Dead", + "species": "Alien", + "type": "Parasite", + "gender": "Male", + "origin": { "name": "unknown", "url": "" }, + "location": { + "name": "Earth (Replacement Dimension)", + "url": "https://rickandmortyapi.com/api/location/20" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/31.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/15"], + "url": "https://rickandmortyapi.com/api/character/31", + "created": "2017-11-05T09:15:11.286Z" + }, + { + "id": 32, + "name": "Bearded Lady", + "status": "Dead", + "species": "Alien", + "type": "Parasite", + "gender": "Female", + "origin": { "name": "unknown", "url": "" }, + "location": { + "name": "Earth (Replacement Dimension)", + "url": "https://rickandmortyapi.com/api/location/20" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/32.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/15"], + "url": "https://rickandmortyapi.com/api/character/32", + "created": "2017-11-05T09:18:04.184Z" + }, + { + "id": 33, + "name": "Beebo", + "status": "Dead", + "species": "Alien", + "type": "", + "gender": "Male", + "origin": { + "name": "Venzenulon 7", + "url": "https://rickandmortyapi.com/api/location/10" + }, + "location": { + "name": "Venzenulon 7", + "url": "https://rickandmortyapi.com/api/location/10" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/33.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/29"], + "url": "https://rickandmortyapi.com/api/character/33", + "created": "2017-11-05T09:21:55.595Z" + }, + { + "id": 34, + "name": "Benjamin", + "status": "Alive", + "species": "Poopybutthole", + "type": "", "gender": "Male", "origin": { "name": "unknown", "url": "" }, "location": { "name": "Interdimensional Cable", "url": "https://rickandmortyapi.com/api/location/6" }, - "image": "https://rickandmortyapi.com/api/character/avatar/20.jpeg", + "image": "https://rickandmortyapi.com/api/character/avatar/34.jpeg", + "episode": [ + "https://rickandmortyapi.com/api/episode/8", + "https://rickandmortyapi.com/api/episode/13", + "https://rickandmortyapi.com/api/episode/17" + ], + "url": "https://rickandmortyapi.com/api/character/34", + "created": "2017-11-05T09:24:04.748Z" + }, + { + "id": 35, + "name": "Bepisian", + "status": "Alive", + "species": "Alien", + "type": "Bepisian", + "gender": "unknown", + "origin": { + "name": "Bepis 9", + "url": "https://rickandmortyapi.com/api/location/11" + }, + "location": { + "name": "Bepis 9", + "url": "https://rickandmortyapi.com/api/location/11" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/35.jpeg", + "episode": [ + "https://rickandmortyapi.com/api/episode/1", + "https://rickandmortyapi.com/api/episode/11", + "https://rickandmortyapi.com/api/episode/19", + "https://rickandmortyapi.com/api/episode/25" + ], + "url": "https://rickandmortyapi.com/api/character/35", + "created": "2017-11-05T09:27:38.491Z" + }, + { + "id": 36, + "name": "Beta-Seven", + "status": "Alive", + "species": "Alien", + "type": "Hivemind", + "gender": "unknown", + "origin": { "name": "unknown", "url": "" }, + "location": { "name": "unknown", "url": "" }, + "image": "https://rickandmortyapi.com/api/character/avatar/36.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/14"], + "url": "https://rickandmortyapi.com/api/character/36", + "created": "2017-11-05T09:31:08.952Z" + }, + { + "id": 37, + "name": "Beth Sanchez", + "status": "Alive", + "species": "Human", + "type": "", + "gender": "Female", + "origin": { + "name": "Earth (C-500A)", + "url": "https://rickandmortyapi.com/api/location/23" + }, + "location": { + "name": "Earth (C-500A)", + "url": "https://rickandmortyapi.com/api/location/23" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/37.jpeg", "episode": ["https://rickandmortyapi.com/api/episode/8"], - "url": "https://rickandmortyapi.com/api/character/20", - "created": "2017-11-04T22:34:53.659Z" + "url": "https://rickandmortyapi.com/api/character/37", + "created": "2017-11-05T09:38:22.960Z" + }, + { + "id": 38, + "name": "Beth Smith", + "status": "Alive", + "species": "Human", + "type": "", + "gender": "Female", + "origin": { + "name": "Earth (C-137)", + "url": "https://rickandmortyapi.com/api/location/1" + }, + "location": { + "name": "Earth (C-137)", + "url": "https://rickandmortyapi.com/api/location/1" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/38.jpeg", + "episode": [ + "https://rickandmortyapi.com/api/episode/1", + "https://rickandmortyapi.com/api/episode/2", + "https://rickandmortyapi.com/api/episode/3", + "https://rickandmortyapi.com/api/episode/4", + "https://rickandmortyapi.com/api/episode/5", + "https://rickandmortyapi.com/api/episode/6", + "https://rickandmortyapi.com/api/episode/22", + "https://rickandmortyapi.com/api/episode/51" + ], + "url": "https://rickandmortyapi.com/api/character/38", + "created": "2017-11-05T09:48:44.230Z" + }, + { + "id": 39, + "name": "Beth Smith", + "status": "Alive", + "species": "Human", + "type": "", + "gender": "Female", + "origin": { + "name": "Earth (Evil Rick's Target Dimension)", + "url": "https://rickandmortyapi.com/api/location/34" + }, + "location": { + "name": "Earth (Evil Rick's Target Dimension)", + "url": "https://rickandmortyapi.com/api/location/34" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/39.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/10"], + "url": "https://rickandmortyapi.com/api/character/39", + "created": "2017-11-05T09:52:31.777Z" + }, + { + "id": 40, + "name": "Beth's Mytholog", + "status": "Dead", + "species": "Mythological Creature", + "type": "Mytholog", + "gender": "Female", + "origin": { + "name": "Nuptia 4", + "url": "https://rickandmortyapi.com/api/location/13" + }, + "location": { + "name": "Nuptia 4", + "url": "https://rickandmortyapi.com/api/location/13" + }, + "image": "https://rickandmortyapi.com/api/character/avatar/40.jpeg", + "episode": ["https://rickandmortyapi.com/api/episode/18"], + "url": "https://rickandmortyapi.com/api/character/40", + "created": "2017-11-05T10:02:26.701Z" } ] } From 3fdbef02b79ac9623f29062e7aa2a712b2852880 Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:33:58 +0000 Subject: [PATCH 03/11] refactor: use characterId in GameCard for better scalability and balancing --- src/components/game/CollectionTab.tsx | 12 +-- src/components/game/GameCard.tsx | 37 +++++----- src/components/game/PackOpening.tsx | 9 ++- src/pages/Collection.tsx | 36 ++++++--- src/store/gameStore.ts | 101 +++++++++++++++++--------- src/types/game.ts | 13 +--- 6 files changed, 125 insertions(+), 83 deletions(-) diff --git a/src/components/game/CollectionTab.tsx b/src/components/game/CollectionTab.tsx index 6ed34de..d5f6510 100644 --- a/src/components/game/CollectionTab.tsx +++ b/src/components/game/CollectionTab.tsx @@ -1,4 +1,4 @@ -import { useGameStore } from '@/store/gameStore'; +import { useGameStore, resolveCardStats } from '@/store/gameStore'; import { GameCard } from './GameCard'; import { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; @@ -26,11 +26,11 @@ export function CollectionTab() { const displayCards = useMemo(() => { return inventory - .filter(card => - card.name.toLowerCase().includes(search.toLowerCase()) || - card.characterName.toLowerCase().includes(search.toLowerCase()) + .map(card => ({ card, stats: resolveCardStats(card) })) + .filter(({ stats }) => + stats.character.name.toLowerCase().includes(search.toLowerCase()) ) - .sort((a, b) => b.income - a.income) + .sort((a, b) => b.stats.income - a.stats.income) .slice(0, 4); }, [inventory, search]); @@ -62,7 +62,7 @@ export function CollectionTab() { {displayCards.length > 0 ? (
- {displayCards.map((card) => { + {displayCards.map(({ card }) => { const isInSlot = activeSlots.some((s) => s?.id === card.id); return (
diff --git a/src/components/game/GameCard.tsx b/src/components/game/GameCard.tsx index 659b12f..a0743f5 100644 --- a/src/components/game/GameCard.tsx +++ b/src/components/game/GameCard.tsx @@ -10,7 +10,7 @@ import { Sword, } from "lucide-react"; import { formatNumber } from "@/lib/utils"; -import cardTypes from "@/data/cardTypes.json"; +import { resolveCardStats } from "@/store/gameStore"; interface GameCardProps { card: GameCardType; @@ -42,11 +42,12 @@ const rarityConfig: Record = { }; export function GameCard({ card, onClick, isActive }: GameCardProps) { + const stats = resolveCardStats(card); + const { character, income, power } = stats; + const types = card.types || []; const isHolo = types.includes("HOLO"); const isFullArt = types.includes("FULL_ART"); - const isRare = types.includes("RARE"); - const isSilver = types.includes("SILVER"); const isGold = types.includes("GOLD"); const isRevert = types.includes("REVERT"); @@ -68,12 +69,10 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) { const config = rarityConfig[primaryType] || rarityConfig.COMMON; - let imgSrc = "https://rickandmortyapi.com/api/character/avatar/19.jpeg"; + let imgSrc = `https://rickandmortyapi.com/api/character/avatar/${character.avatarId}.jpeg`; - if (card.customImage) { - imgSrc = card.customImage; - } else if (card.avatarId) { - imgSrc = `https://rickandmortyapi.com/api/character/avatar/${card.avatarId}.jpeg`; + if (character.customImage) { + imgSrc = character.customImage; } const imageFilter = isRevert @@ -106,7 +105,7 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) { {isFullArt ? ( {card.characterName} @@ -114,7 +113,7 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) {
{card.characterName} @@ -124,14 +123,14 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) {
- {card.status} + {character.status}
@@ -140,10 +139,10 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) { className={`p-3 space-y-1 ${isFullArt ? "bg-background/80 backdrop-blur-sm" : ""}`} >

- {card.characterName} + {character.name}

- {card.origin} + {character.origin}

@@ -157,18 +156,20 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) {
- +{formatNumber(card.income)}/s + +{formatNumber(income)}/s
- {formatNumber(card.power)} + {formatNumber(power)}
- {card.species} + + {character.species} +
diff --git a/src/components/game/PackOpening.tsx b/src/components/game/PackOpening.tsx index 4a7cfe4..c66721b 100644 --- a/src/components/game/PackOpening.tsx +++ b/src/components/game/PackOpening.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { useGameStore } from "@/store/gameStore"; +import { useGameStore, resolveCardStats } from "@/store/gameStore"; import { GameCard } from "./GameCard"; import { Button } from "@/components/ui/button"; import { Package, Sparkles, X, ChevronRight, Lock } from "lucide-react"; @@ -102,12 +102,13 @@ export function PackOpening({ packId }: PackOpeningProps) { }; const handleSellCard = (card: GameCardType) => { + const stats = resolveCardStats(card); sellCard(card.id, card); setSoldCards((prev) => [...prev, card.id]); const sellPrice = Math.floor( - card.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER, + stats.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER, ); - toast.success(`${card.characterName} sold!`, { + toast.success(`${stats.character.name} sold!`, { description: `Gained ${formatCurrency(sellPrice)} Mega Seeds.`, }); }; @@ -300,7 +301,7 @@ export function PackOpening({ packId }: PackOpeningProps) { SELL FOR{" "} {formatCurrency( Math.floor( - card.income * + resolveCardStats(card).income * GAME_CONFIG.SELL_PRICE_MULTIPLIER, ), )} diff --git a/src/pages/Collection.tsx b/src/pages/Collection.tsx index d231716..fe15f6b 100644 --- a/src/pages/Collection.tsx +++ b/src/pages/Collection.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import { useGameStore } from '@/store/gameStore'; +import { useGameStore, resolveCardStats } from '@/store/gameStore'; import { Header } from '@/components/game/Header'; import { GameCard } from '@/components/game/GameCard'; import { Footer } from '@/components/game/Footer'; @@ -32,16 +32,16 @@ const Collection = () => { const filteredCards = useMemo(() => { return inventory - .filter((card) => { - const matchesSearch = card.name.toLowerCase().includes(search.toLowerCase()) || - card.characterName.toLowerCase().includes(search.toLowerCase()); + .map(card => ({ card, stats: resolveCardStats(card) })) + .filter(({ card, stats }) => { + const matchesSearch = stats.character.name.toLowerCase().includes(search.toLowerCase()); const matchesType = typeFilter === 'ALL' || (card.types || []).includes(typeFilter); return matchesSearch && matchesType; }) .sort((a, b) => { - if (sortBy === 'newest') return b.timestamp - a.timestamp; - if (sortBy === 'oldest') return a.timestamp - b.timestamp; - if (sortBy === 'income') return b.income - a.income; + if (sortBy === 'newest') return b.card.timestamp - a.card.timestamp; + if (sortBy === 'oldest') return a.card.timestamp - b.card.timestamp; + if (sortBy === 'income') return b.stats.income - a.stats.income; return 0; }); }, [inventory, search, typeFilter, sortBy]); @@ -57,18 +57,30 @@ const Collection = () => { const totalProfit = inventory .filter(c => selectedIds.includes(c.id)) - .reduce((acc, c) => acc + (c.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER), 0); + .reduce((acc, c) => { + const stats = resolveCardStats(c); + return acc + (stats.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER); + }, 0); - if (confirm(`Sell ${selectedIds.length} selected cards for ${totalProfit.toLocaleString()} Mega Seeds?`)) { + if (confirm(`Sell ${selectedIds.length} selected cards for ${Math.floor(totalProfit).toLocaleString()} Mega Seeds?`)) { sellCards(selectedIds); toast.success(`Sold ${selectedIds.length} cards`, { - description: `You received ${totalProfit.toLocaleString()} Mega Seeds.`, + description: `You received ${Math.floor(totalProfit).toLocaleString()} Mega Seeds.`, }); setSelectedIds([]); setIsSellMode(false); } }; + const selectedTotalProfit = useMemo(() => { + return inventory + .filter(c => selectedIds.includes(c.id)) + .reduce((acc, c) => { + const stats = resolveCardStats(c); + return acc + (stats.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER); + }, 0); + }, [inventory, selectedIds]); + return (
@@ -143,7 +155,7 @@ const Collection = () => { {/* Results */} {filteredCards.length > 0 ? (
- {filteredCards.map((card) => { + {filteredCards.map(({ card }) => { const isSelected = selectedIds.includes(card.id); return (
{

{selectedIds.length} Selected

- Total: {inventory.filter(c => selectedIds.includes(c.id)).reduce((acc, c) => acc + (c.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER), 0).toLocaleString()} Seeds + Total: {Math.floor(selectedTotalProfit).toLocaleString()} Seeds

diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 92940d4..dac1e4a 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -75,33 +75,41 @@ const generateCard = ( } } - const combinedMultiplier = selectedTypes.reduce((acc, typeId) => { - const type = (cardTypes as CardType[]).find((t) => t.id === typeId); - return acc * (type?.multiplier || 1); - }, 1); - - const finalIncome = Math.floor(character.baseMultiplier * combinedMultiplier); - const finalPower = Math.floor(character.basePower * combinedMultiplier); const timestamp = Date.now(); const uuid = crypto.randomUUID(); return { id: `${character.name.replace(/\s+/g, "-").toLowerCase()}-${selectedTypes.join("-")}-${timestamp}-${uuid.slice(0, 8)}`, - name: character.name, - characterName: character.name, + characterId: character.id, types: selectedTypes, - income: finalIncome, - power: finalPower, - avatarId: character.avatarId, - customImage: character.customImage, - origin: character.origin, - location: character.location, - status: character.status, - species: character.species, timestamp, }; }; +export const resolveCardStats = (card: GameCard) => { + const character = + (characters as Character[]).find((c) => c.id === card.characterId) || + (characters as Character[])[0]; + + if (card.income !== undefined && card.power !== undefined) { + return { income: card.income, power: card.power, character }; + } + + const combinedMultiplier = card.types.reduce((acc, typeId) => { + const type = (cardTypes as CardType[]).find((t) => t.id === typeId); + return acc * (type?.multiplier || 1); + }, 1); + + const baseIncome = card.income !== undefined ? card.income : character.baseMultiplier; + const basePower = card.power !== undefined ? card.power : character.basePower; + + return { + income: Math.floor(baseIncome * combinedMultiplier), + power: Math.floor(basePower * combinedMultiplier), + character, + }; +}; + export const useGameStore = create()( persist( (set, get) => ({ @@ -143,8 +151,9 @@ export const useGameStore = create()( if (!targetCard) return s; + const stats = resolveCardStats(targetCard); const sellPrice = Math.floor( - targetCard.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER, + stats.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER, ); return { inventory: s.inventory.filter((c) => c.id !== cardId), @@ -158,11 +167,12 @@ export const useGameStore = create()( sellCards: (cardIds) => set((s) => { const cardsToSell = s.inventory.filter((c) => cardIds.includes(c.id)); - const totalProfit = cardsToSell.reduce( - (acc, c) => - acc + Math.floor(c.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER), - 0, - ); + const totalProfit = cardsToSell.reduce((acc, c) => { + const stats = resolveCardStats(c); + return ( + acc + Math.floor(stats.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER) + ); + }, 0); return { inventory: s.inventory.filter((c) => !cardIds.includes(c.id)), @@ -258,9 +268,11 @@ export const useGameStore = create()( GAME_CONFIG.CARD_GENERATION.ENEMY_WEIGHTS, GAME_CONFIG.CARD_GENERATION.DEFAULT_COMBINE_CHANCE, ); + const stats = resolveCardStats(newEnemy); const scaleFactor = 1 + (nextLvl - 1) * GAME_CONFIG.DIMENSIONS.SCALE_FACTOR; - newEnemy.power = Math.floor(newEnemy.power * scaleFactor); + newEnemy.power = Math.floor(stats.power * scaleFactor); + newEnemy.income = stats.income; set((s) => { const nextLevel = s.dimensionLevel + 1; @@ -345,9 +357,9 @@ export const useGameStore = create()( }), { name: "rick-morty-idle-save", - version: 4, + version: 5, migrate: (persistedState: unknown, version: number) => { - let state = persistedState as PersistedGameState; + let state = persistedState as any; if (version === 0) { state = { @@ -359,7 +371,7 @@ export const useGameStore = create()( } if (version < 2) { - const migrateCard = (card: GameCard): GameCard => { + const migrateCard = (card: any): any => { if (!card) return card; if (!card.origin || card.origin === "none") { const charData = (characters as Character[]).find( @@ -377,7 +389,7 @@ export const useGameStore = create()( state = { ...state, inventory: state.inventory?.map(migrateCard) || [], - activeSlots: state.activeSlots?.map((slot) => + activeSlots: state.activeSlots?.map((slot: any) => slot ? migrateCard(slot) : null, ) || [null, null, null, null], }; @@ -386,7 +398,7 @@ export const useGameStore = create()( if (version < 3) { // Fix duplicate IDs by regenerating them for all cards const oldInv = state.inventory || []; - const newInventory = oldInv.map((card: GameCard) => { + const newInventory = oldInv.map((card: any) => { const uuid = crypto.randomUUID().slice(0, 8); const baseId = card.id ? card.id.split("-").slice(0, 2).join("-") @@ -399,11 +411,9 @@ export const useGameStore = create()( const newActiveSlots = ( state.activeSlots || [null, null, null, null] - ).map((slot) => { + ).map((slot: any) => { if (!slot) return null; - const oldIndex = oldInv.findIndex( - (c: GameCard) => c.id === slot.id, - ); + const oldIndex = oldInv.findIndex((c: any) => c.id === slot.id); if (oldIndex !== -1 && newInventory[oldIndex]) { return newInventory[oldIndex]; } @@ -424,6 +434,31 @@ export const useGameStore = create()( }; } + if (version < 5) { + const migrateCardV5 = (card: any): GameCard => { + if (!card || card.characterId) return card; + const charData = (characters as Character[]).find( + (c) => c.name === (card.characterName || card.name), + ); + return { + id: card.id, + characterId: charData?.id || 1, + types: card.types || ["COMMON"], + timestamp: card.timestamp || Date.now(), + income: card.income, + power: card.power, + }; + }; + + state = { + ...state, + inventory: state.inventory?.map(migrateCardV5) || [], + activeSlots: state.activeSlots?.map((slot: any) => + slot ? migrateCardV5(slot) : null, + ) || [null, null, null, null], + }; + } + return state; }, onRehydrateStorage: () => (state: GameState | undefined) => { diff --git a/src/types/game.ts b/src/types/game.ts index bd290b0..e609139 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -23,18 +23,11 @@ export interface Character { export interface GameCard { id: string; - name: string; - characterName: string; + characterId: number; types: string[]; // e.g. ["HOLO", "FULL_ART"] - income: number; - power: number; - avatarId?: number; - customImage?: string; - origin: string; - location: string; - status: string; - species: string; timestamp: number; + income?: number; // Optional: used for enemies or special overrides + power?: number; // Optional: used for enemies or special overrides } export interface GameState { From f355c028d66e7d7f860d5cd3d6001891f3cb8ba0 Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:39:52 +0000 Subject: [PATCH 04/11] fix: make resolveCardStats robust and filter null inventory items --- src/components/game/CollectionTab.tsx | 7 +++--- src/pages/Collection.tsx | 18 +++++++++------ src/store/gameStore.ts | 33 +++++++++++++++++++-------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/components/game/CollectionTab.tsx b/src/components/game/CollectionTab.tsx index d5f6510..44becf3 100644 --- a/src/components/game/CollectionTab.tsx +++ b/src/components/game/CollectionTab.tsx @@ -26,9 +26,10 @@ export function CollectionTab() { const displayCards = useMemo(() => { return inventory - .map(card => ({ card, stats: resolveCardStats(card) })) - .filter(({ stats }) => - stats.character.name.toLowerCase().includes(search.toLowerCase()) + .filter(Boolean) + .map((card) => ({ card, stats: resolveCardStats(card) })) + .filter(({ stats }) => + stats.character.name.toLowerCase().includes(search.toLowerCase()), ) .sort((a, b) => b.stats.income - a.stats.income) .slice(0, 4); diff --git a/src/pages/Collection.tsx b/src/pages/Collection.tsx index fe15f6b..234fec2 100644 --- a/src/pages/Collection.tsx +++ b/src/pages/Collection.tsx @@ -32,16 +32,20 @@ const Collection = () => { const filteredCards = useMemo(() => { return inventory - .map(card => ({ card, stats: resolveCardStats(card) })) - .filter(({ card, stats }) => { - const matchesSearch = stats.character.name.toLowerCase().includes(search.toLowerCase()); - const matchesType = typeFilter === 'ALL' || (card.types || []).includes(typeFilter); + .filter(Boolean) + .map((card) => ({ card, stats: resolveCardStats(card) })) + .filter(({ stats }) => { + const matchesSearch = stats.character.name + .toLowerCase() + .includes(search.toLowerCase()); + const matchesType = + typeFilter === "ALL" || (card.types || []).includes(typeFilter); return matchesSearch && matchesType; }) .sort((a, b) => { - if (sortBy === 'newest') return b.card.timestamp - a.card.timestamp; - if (sortBy === 'oldest') return a.card.timestamp - b.card.timestamp; - if (sortBy === 'income') return b.stats.income - a.stats.income; + if (sortBy === "newest") return b.card.timestamp - a.card.timestamp; + if (sortBy === "oldest") return a.card.timestamp - b.card.timestamp; + if (sortBy === "income") return b.stats.income - a.stats.income; return 0; }); }, [inventory, search, typeFilter, sortBy]); diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index dac1e4a..b833b04 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -87,21 +87,34 @@ const generateCard = ( }; export const resolveCardStats = (card: GameCard) => { + if (!card) { + const character = (characters as Character[])[0]; + return { + income: 0, + power: 0, + character, + }; + } + const character = (characters as Character[]).find((c) => c.id === card.characterId) || (characters as Character[])[0]; + const types = card.types || []; + if (card.income !== undefined && card.power !== undefined) { return { income: card.income, power: card.power, character }; } - const combinedMultiplier = card.types.reduce((acc, typeId) => { + const combinedMultiplier = types.reduce((acc, typeId) => { const type = (cardTypes as CardType[]).find((t) => t.id === typeId); return acc * (type?.multiplier || 1); }, 1); - const baseIncome = card.income !== undefined ? card.income : character.baseMultiplier; - const basePower = card.power !== undefined ? card.power : character.basePower; + const baseIncome = + card.income !== undefined ? card.income : character.baseMultiplier; + const basePower = + card.power !== undefined ? card.power : character.basePower; return { income: Math.floor(baseIncome * combinedMultiplier), @@ -435,8 +448,9 @@ export const useGameStore = create()( } if (version < 5) { - const migrateCardV5 = (card: any): GameCard => { - if (!card || card.characterId) return card; + const migrateCardV5 = (card: any): GameCard | null => { + if (!card) return null; + if (card.characterId) return card; const charData = (characters as Character[]).find( (c) => c.name === (card.characterName || card.name), ); @@ -452,10 +466,11 @@ export const useGameStore = create()( state = { ...state, - inventory: state.inventory?.map(migrateCardV5) || [], - activeSlots: state.activeSlots?.map((slot: any) => - slot ? migrateCardV5(slot) : null, - ) || [null, null, null, null], + inventory: state.inventory?.map(migrateCardV5).filter(Boolean) || [], + activeSlots: + state.activeSlots?.map((slot: any) => + slot ? migrateCardV5(slot) : null, + ) || [null, null, null, null], }; } From db7416cb9974f015bac5696ec444dcb6408fd486 Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:44:59 +0000 Subject: [PATCH 05/11] fix: ReferenceError: isSilver is not defined --- src/components/game/GameCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/game/GameCard.tsx b/src/components/game/GameCard.tsx index a0743f5..db63d6c 100644 --- a/src/components/game/GameCard.tsx +++ b/src/components/game/GameCard.tsx @@ -49,6 +49,7 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) { const isHolo = types.includes("HOLO"); const isFullArt = types.includes("FULL_ART"); const isGold = types.includes("GOLD"); + const isSilver = types.includes("SILVER"); const isRevert = types.includes("REVERT"); const primaryType = types.includes("REVERT") From c9eb7a35ba8a066309884db239c8ace9ec93612a Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:33:20 +0000 Subject: [PATCH 06/11] refactor: split gameStore into slices and fix dynamic power resolution in Dimensions --- src/data/characters.json | 4 +- src/pages/Dimension.tsx | 32 ++- src/store/cardUtils.ts | 86 +++++++ src/store/gameStore.ts | 392 ++--------------------------- src/store/slices/currencySlice.ts | 47 ++++ src/store/slices/dimensionSlice.ts | 97 +++++++ src/store/slices/inventorySlice.ts | 97 +++++++ src/store/slices/packSlice.ts | 54 ++++ src/store/slices/upgradeSlice.ts | 59 +++++ 9 files changed, 492 insertions(+), 376 deletions(-) create mode 100644 src/store/cardUtils.ts create mode 100644 src/store/slices/currencySlice.ts create mode 100644 src/store/slices/dimensionSlice.ts create mode 100644 src/store/slices/inventorySlice.ts create mode 100644 src/store/slices/packSlice.ts create mode 100644 src/store/slices/upgradeSlice.ts diff --git a/src/data/characters.json b/src/data/characters.json index 1664a67..2f9a87b 100644 --- a/src/data/characters.json +++ b/src/data/characters.json @@ -180,7 +180,7 @@ "location": "Citadel of Ricks", "avatarId": 14, "baseMultiplier": 60, - "basePower": 120 + "basePower": 125 }, { "id": 15, @@ -232,7 +232,7 @@ "location": "Citadel of Ricks", "avatarId": 18, "baseMultiplier": 65, - "basePower": 130 + "basePower": 135 }, { "id": 19, diff --git a/src/pages/Dimension.tsx b/src/pages/Dimension.tsx index c8fc4dd..dd3d9d8 100644 --- a/src/pages/Dimension.tsx +++ b/src/pages/Dimension.tsx @@ -4,7 +4,7 @@ import { Footer } from "@/components/game/Footer"; import { Button } from "@/components/ui/button"; import { Link } from "react-router-dom"; import { Map, ArrowLeft, Sword, History, Zap, Beaker } from "lucide-react"; -import { useGameStore } from "@/store/gameStore"; +import { useGameStore, resolveCardStats } from "@/store/gameStore"; import { GameCard as GameCardType } from "@/types/game"; import { GameCard } from "@/components/game/GameCard"; import { toast } from "sonner"; @@ -23,7 +23,6 @@ const Dimension = () => { startDimension, nextDimensionLevel, resetDimension, - generateRandomCard, } = useGameStore(); const [isFighting, setIsFighting] = useState(false); @@ -31,15 +30,25 @@ const Dimension = () => { // Find player's strongest card and calculate base vs bonus const playerStats = useMemo(() => { if (inventory.length === 0) return null; - const strongest = [...inventory].sort((a, b) => b.power - a.power)[0]; + + // Process inventory to resolve stats first + const resolvedInventory = inventory + .filter(Boolean) + .map(card => ({ + card, + stats: resolveCardStats(card) + })) + .sort((a, b) => b.stats.power - a.stats.power); + + const strongest = resolvedInventory[0]; // Lab Power Upgrade const powerMultiplier = 1 + upgrades.power * GAME_CONFIG.UPGRADES.power.BONUS_PER_LEVEL; - const totalPower = Math.floor(strongest.power * powerMultiplier); + const totalPower = Math.floor(strongest.stats.power * powerMultiplier); return { - card: strongest, - basePower: strongest.power, + card: strongest.card, + basePower: strongest.stats.power, totalPower, bonusPercent: Math.round(upgrades.power * GAME_CONFIG.UPGRADES.power.BONUS_PER_LEVEL * 100), }; @@ -59,6 +68,8 @@ const Dimension = () => { const handleFight = () => { if (!playerStats || !currentEnemy) return; + + const enemyStats = resolveCardStats(currentEnemy); setIsFighting(true); @@ -68,7 +79,7 @@ const Dimension = () => { description: `You've reached the maximum level and unlocked all rewards!`, }); resetDimension(GAME_CONFIG.DIMENSIONS.MAX_LEVEL_REWARD); - } else if (playerStats.totalPower >= currentEnemy.power) { + } else if (playerStats.totalPower >= enemyStats.power) { const { bonus, milestoneUnlocked } = nextDimensionLevel(); if (milestoneUnlocked) { @@ -94,6 +105,11 @@ const Dimension = () => { }, 1500); }; + const enemyPower = useMemo(() => { + if (!currentEnemy) return 0; + return resolveCardStats(currentEnemy).power; + }, [currentEnemy]); + return (
@@ -242,7 +258,7 @@ const Dimension = () => {
- {formatNumber(currentEnemy?.power || 0)} + {formatNumber(enemyPower)}
diff --git a/src/store/cardUtils.ts b/src/store/cardUtils.ts new file mode 100644 index 0000000..f4f586a --- /dev/null +++ b/src/store/cardUtils.ts @@ -0,0 +1,86 @@ +import { Character, GameCard, CardType } from "@/types/game"; +import characters from "@/data/characters.json"; +import cardTypes from "@/data/cardTypes.json"; +import { GAME_CONFIG } from "@/config/gameConfig"; + +export const generateCard = ( + weights: Record = GAME_CONFIG.CARD_GENERATION.DEFAULT_WEIGHTS, + combineChance: number = GAME_CONFIG.CARD_GENERATION.DEFAULT_COMBINE_CHANCE, +): GameCard => { + const character = (characters as Character[])[ + Math.floor(Math.random() * characters.length) + ]; + + const typeRoll = Math.random(); + let baseTypeId = "COMMON"; + let cumulative = 0; + + for (const [type, weight] of Object.entries(weights)) { + cumulative += weight; + if (typeRoll <= cumulative) { + baseTypeId = type; + break; + } + } + + const baseType = (cardTypes as CardType[]).find((t) => t.id === baseTypeId); + const selectedTypes = [baseTypeId]; + + if (baseType?.canCombine && Math.random() < combineChance) { + const validExtraTypes = baseType.combinesWith || []; + if (validExtraTypes.length > 0) { + const extraTypeId = + validExtraTypes[Math.floor(Math.random() * validExtraTypes.length)]; + if (!selectedTypes.includes(extraTypeId)) { + selectedTypes.push(extraTypeId); + } + } + } + + const timestamp = Date.now(); + const uuid = crypto.randomUUID(); + + return { + id: `${character.name.replace(/\s+/g, "-").toLowerCase()}-${selectedTypes.join("-")}-${timestamp}-${uuid.slice(0, 8)}`, + characterId: character.id, + types: selectedTypes, + timestamp, + }; +}; + +export const resolveCardStats = (card: GameCard) => { + if (!card) { + const character = (characters as Character[])[0]; + return { + income: 0, + power: 0, + character, + }; + } + + const character = + (characters as Character[]).find((c) => c.id === card.characterId) || + (characters as Character[])[0]; + + const types = card.types || []; + + if (card.income !== undefined && card.power !== undefined) { + return { income: card.income, power: card.power, character }; + } + + const combinedMultiplier = types.reduce((acc, typeId) => { + const type = (cardTypes as CardType[]).find((t) => t.id === typeId); + return acc * (type?.multiplier || 1); + }, 1); + + const baseIncome = + card.income !== undefined ? card.income : character.baseMultiplier; + const basePower = + card.power !== undefined ? card.power : character.basePower; + + return { + income: Math.floor(baseIncome * combinedMultiplier), + power: Math.floor(basePower * combinedMultiplier), + character, + }; +}; diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index b833b04..6ce6b62 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -1,372 +1,32 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { Character, GameCard } from "@/types/game"; import characters from "@/data/characters.json"; -import cardTypes from "@/data/cardTypes.json"; -import packs from "@/data/packs.json"; -import { GAME_CONFIG } from "@/config/gameConfig"; -import { - CardType, - GameCard, - Character, - GameState as BaseGameState, -} from "@/types/game"; -import { - trackPackOpening, - trackUpgrade, - trackDimensionStart, - trackDimensionEnd, -} from "@/lib/analytics"; +import { generateCard } from "./cardUtils"; -interface GameState extends BaseGameState { - addCard: (card: GameCard) => boolean; - addCards: (cards: GameCard[]) => boolean; - sellCard: (cardId: string, card?: GameCard) => void; - sellCards: (cardIds: string[]) => void; - isPackUnlocked: (packId: string) => boolean; - buyPack: (packId: string) => GameCard[] | null; - generateRandomCard: ( - weights?: Record, - combineChance?: number, - ) => GameCard; - toggleSlot: (slotIndex: number, card: GameCard) => void; - updateSeeds: (amount: number) => void; - startDimension: () => boolean; - nextDimensionLevel: () => { bonus: number; milestoneUnlocked: string | null }; - resetDimension: (reward: number) => void; - buyUpgrade: (type: "seeds" | "power") => boolean; - getUpgradeCost: (type: "seeds" | "power") => number; - hardReset: () => void; - setLastSaved: (ts: number) => void; -} +import { createCurrencySlice, CurrencySlice } from "./slices/currencySlice"; +import { createInventorySlice, InventorySlice } from "./slices/inventorySlice"; +import { createDimensionSlice, DimensionSlice } from "./slices/dimensionSlice"; +import { createUpgradeSlice, UpgradeSlice } from "./slices/upgradeSlice"; +import { createPackSlice, PackSlice } from "./slices/packSlice"; -type PersistedGameState = GameState; +// Re-export utilities for component usage +export { resolveCardStats } from "./cardUtils"; -const generateCard = ( - weights: Record = GAME_CONFIG.CARD_GENERATION.DEFAULT_WEIGHTS, - combineChance: number = GAME_CONFIG.CARD_GENERATION.DEFAULT_COMBINE_CHANCE, -): GameCard => { - const character = (characters as Character[])[ - Math.floor(Math.random() * characters.length) - ]; +export type GameStore = CurrencySlice & + InventorySlice & + DimensionSlice & + UpgradeSlice & + PackSlice; - const typeRoll = Math.random(); - let baseTypeId = "COMMON"; - let cumulative = 0; - - for (const [type, weight] of Object.entries(weights)) { - cumulative += weight; - if (typeRoll <= cumulative) { - baseTypeId = type; - break; - } - } - - const baseType = (cardTypes as CardType[]).find((t) => t.id === baseTypeId); - const selectedTypes = [baseTypeId]; - - if (baseType?.canCombine && Math.random() < combineChance) { - const validExtraTypes = baseType.combinesWith || []; - if (validExtraTypes.length > 0) { - const extraTypeId = - validExtraTypes[Math.floor(Math.random() * validExtraTypes.length)]; - if (!selectedTypes.includes(extraTypeId)) { - selectedTypes.push(extraTypeId); - } - } - } - - const timestamp = Date.now(); - const uuid = crypto.randomUUID(); - - return { - id: `${character.name.replace(/\s+/g, "-").toLowerCase()}-${selectedTypes.join("-")}-${timestamp}-${uuid.slice(0, 8)}`, - characterId: character.id, - types: selectedTypes, - timestamp, - }; -}; - -export const resolveCardStats = (card: GameCard) => { - if (!card) { - const character = (characters as Character[])[0]; - return { - income: 0, - power: 0, - character, - }; - } - - const character = - (characters as Character[]).find((c) => c.id === card.characterId) || - (characters as Character[])[0]; - - const types = card.types || []; - - if (card.income !== undefined && card.power !== undefined) { - return { income: card.income, power: card.power, character }; - } - - const combinedMultiplier = types.reduce((acc, typeId) => { - const type = (cardTypes as CardType[]).find((t) => t.id === typeId); - return acc * (type?.multiplier || 1); - }, 1); - - const baseIncome = - card.income !== undefined ? card.income : character.baseMultiplier; - const basePower = - card.power !== undefined ? card.power : character.basePower; - - return { - income: Math.floor(baseIncome * combinedMultiplier), - power: Math.floor(basePower * combinedMultiplier), - character, - }; -}; - -export const useGameStore = create()( +export const useGameStore = create()( persist( - (set, get) => ({ - seeds: GAME_CONFIG.INITIAL_SEEDS, - inventory: [], - maxInventory: GAME_CONFIG.INITIAL_MAX_INVENTORY, - activeSlots: [null, null, null, null], - dimensionLevel: 1, - maxDimensionLevel: 1, - isDimensionActive: false, - currentEnemy: null, - upgrades: { - seeds: 0, - power: 0, - }, - lastSaved: Date.now(), - - addCard: (card) => { - const { inventory, maxInventory } = get(); - if (inventory.length >= maxInventory) return false; - set((s) => ({ inventory: [...s.inventory, card] })); - return true; - }, - - addCards: (cards) => { - const { inventory, maxInventory } = get(); - const availableSpace = maxInventory - inventory.length; - if (availableSpace <= 0) return false; - - const cardsToAdd = cards.slice(0, availableSpace); - set((s) => ({ inventory: [...s.inventory, ...cardsToAdd] })); - return true; - }, - - sellCard: (cardId, card) => - set((s) => { - const cardInInventory = s.inventory.find((c) => c.id === cardId); - const targetCard = cardInInventory || card; - - if (!targetCard) return s; - - const stats = resolveCardStats(targetCard); - const sellPrice = Math.floor( - stats.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER, - ); - return { - inventory: s.inventory.filter((c) => c.id !== cardId), - activeSlots: s.activeSlots.map((sl) => - sl?.id === cardId ? null : sl, - ), - seeds: s.seeds + sellPrice, - }; - }), - - sellCards: (cardIds) => - set((s) => { - const cardsToSell = s.inventory.filter((c) => cardIds.includes(c.id)); - const totalProfit = cardsToSell.reduce((acc, c) => { - const stats = resolveCardStats(c); - return ( - acc + Math.floor(stats.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER) - ); - }, 0); - - return { - inventory: s.inventory.filter((c) => !cardIds.includes(c.id)), - activeSlots: s.activeSlots.map((sl) => - sl && cardIds.includes(sl.id) ? null : sl, - ), - seeds: s.seeds + totalProfit, - }; - }), - - isPackUnlocked: (packId) => { - const { maxDimensionLevel } = get(); - const reqLevel = GAME_CONFIG.DIMENSIONS.PACK_UNLOCKS[packId]; - if (reqLevel === undefined) return false; - return maxDimensionLevel >= reqLevel; - }, - - buyPack: (packId) => { - const { seeds, inventory, maxInventory, isPackUnlocked } = get(); - const pack = packs.find((p) => p.id === packId); - - if (!pack || seeds < pack.cost) return null; - if (inventory.length >= maxInventory) return null; - if (!isPackUnlocked(packId)) return null; - - const newCards: GameCard[] = []; - for (let i = 0; i < pack.cardCount; i++) { - newCards.push(generateCard(pack.weights, pack.combineChance)); - } - - set((state) => ({ - seeds: state.seeds - pack.cost, - })); - - trackPackOpening(pack.name, pack.cost); - - return newCards; - }, - - generateRandomCard: (weights, combineChance) => { - return generateCard(weights, combineChance); - }, - - toggleSlot: (slotIndex, card) => - set((s) => { - const slots = [...s.activeSlots]; - if (slots[slotIndex]?.id === card.id) { - slots[slotIndex] = null; - return { activeSlots: slots }; - } - const cleanedSlots = slots.map((sl) => - sl?.id === card.id ? null : sl, - ); - cleanedSlots[slotIndex] = card; - return { activeSlots: cleanedSlots }; - }), - - updateSeeds: (amount) => - set((s) => ({ seeds: s.seeds + amount, lastSaved: Date.now() })), - - startDimension: () => { - const { seeds, generateRandomCard } = get(); - if (seeds < GAME_CONFIG.DIMENSION_ENTRY_COST) return false; - - const enemy = generateRandomCard( - GAME_CONFIG.CARD_GENERATION.ENEMY_WEIGHTS, - GAME_CONFIG.CARD_GENERATION.DEFAULT_COMBINE_CHANCE, - ); - - set((s) => ({ - seeds: s.seeds - GAME_CONFIG.DIMENSION_ENTRY_COST, - isDimensionActive: true, - dimensionLevel: 1, - currentEnemy: enemy, - })); - trackDimensionStart(1); - return true; - }, - - nextDimensionLevel: () => { - const { dimensionLevel, generateRandomCard } = get(); - let bonus = 0; - let milestoneUnlocked = null; - - if ((dimensionLevel + 1) % GAME_CONFIG.DIMENSIONS.BONUS_STEP === 0) { - bonus = (dimensionLevel + 1) * GAME_CONFIG.DIMENSIONS.BONUS_AMOUNT; - } - - const nextLvl = dimensionLevel + 1; - milestoneUnlocked = GAME_CONFIG.DIMENSIONS.MILESTONES[nextLvl] || null; - - const newEnemy = generateRandomCard( - GAME_CONFIG.CARD_GENERATION.ENEMY_WEIGHTS, - GAME_CONFIG.CARD_GENERATION.DEFAULT_COMBINE_CHANCE, - ); - const stats = resolveCardStats(newEnemy); - const scaleFactor = - 1 + (nextLvl - 1) * GAME_CONFIG.DIMENSIONS.SCALE_FACTOR; - newEnemy.power = Math.floor(stats.power * scaleFactor); - newEnemy.income = stats.income; - - set((s) => { - const nextLevel = s.dimensionLevel + 1; - const newMax = Math.max(s.maxDimensionLevel, nextLevel); - return { - seeds: s.seeds + bonus, - dimensionLevel: nextLevel, - maxDimensionLevel: newMax, - currentEnemy: newEnemy, - }; - }); - - return { bonus, milestoneUnlocked }; - }, - - resetDimension: (reward) => { - const { dimensionLevel } = get(); - trackDimensionEnd(dimensionLevel, reward); - set((s) => ({ - seeds: s.seeds + reward, - isDimensionActive: false, - dimensionLevel: 1, - currentEnemy: null, - })); - }, - - getUpgradeCost: (type) => { - const { upgrades } = get(); - const level = upgrades[type]; - const config = GAME_CONFIG.UPGRADES[type]; - - let cost = config.BASE_COST * Math.pow(config.COST_EXPONENT, level); - const jumps = Math.floor(level / config.JUMP_THRESHOLD); - if (jumps > 0) { - cost = cost * Math.pow(config.JUMP_MULTIPLIER, jumps); - } - - return Math.floor(cost); - }, - - buyUpgrade: (type) => { - const cost = get().getUpgradeCost(type); - const { seeds, upgrades } = get(); - - if (seeds < cost) return false; - - set((s) => { - const newLevel = s.upgrades[type] + 1; - trackUpgrade(type, newLevel, cost); - return { - seeds: s.seeds - cost, - upgrades: { - ...s.upgrades, - [type]: newLevel, - }, - }; - }); - return true; - }, - - hardReset: () => { - set({ - seeds: GAME_CONFIG.INITIAL_SEEDS, - inventory: [], - maxInventory: GAME_CONFIG.INITIAL_MAX_INVENTORY, - activeSlots: [null, null, null, null], - dimensionLevel: 1, - maxDimensionLevel: 1, - isDimensionActive: false, - currentEnemy: null, - upgrades: { seeds: 0, power: 0 }, - lastSaved: Date.now(), - }); - const starter = generateCard( - { COMMON: 0.8, RARE: 0.2, HOLO: 0, FULL_ART: 0 }, - 0, - ); - get().addCard(starter); - }, - - setLastSaved: (ts) => set({ lastSaved: ts }), + (...a) => ({ + ...createCurrencySlice(...a), + ...createInventorySlice(...a), + ...createDimensionSlice(...a), + ...createUpgradeSlice(...a), + ...createPackSlice(...a), }), { name: "rick-morty-idle-save", @@ -402,14 +62,14 @@ export const useGameStore = create()( state = { ...state, inventory: state.inventory?.map(migrateCard) || [], - activeSlots: state.activeSlots?.map((slot: any) => - slot ? migrateCard(slot) : null, - ) || [null, null, null, null], + activeSlots: + state.activeSlots?.map((slot: any) => + slot ? migrateCard(slot) : null, + ) || [null, null, null, null], }; } if (version < 3) { - // Fix duplicate IDs by regenerating them for all cards const oldInv = state.inventory || []; const newInventory = oldInv.map((card: any) => { const uuid = crypto.randomUUID().slice(0, 8); @@ -476,7 +136,7 @@ export const useGameStore = create()( return state; }, - onRehydrateStorage: () => (state: GameState | undefined) => { + onRehydrateStorage: () => (state: GameStore | undefined) => { if (state && state.inventory.length === 0) { const starter = generateCard( { COMMON: 0.8, RARE: 0.2, HOLO: 0, FULL_ART: 0 }, diff --git a/src/store/slices/currencySlice.ts b/src/store/slices/currencySlice.ts new file mode 100644 index 0000000..7b3e947 --- /dev/null +++ b/src/store/slices/currencySlice.ts @@ -0,0 +1,47 @@ +import { StateCreator } from "zustand"; +import { GameStore } from "../gameStore"; +import { GAME_CONFIG } from "@/config/gameConfig"; +import { generateCard } from "../cardUtils"; + +export interface CurrencySlice { + seeds: number; + lastSaved: number; + updateSeeds: (amount: number) => void; + setLastSaved: (ts: number) => void; + hardReset: () => void; +} + +export const createCurrencySlice: StateCreator< + GameStore, + [], + [], + CurrencySlice +> = (set, get) => ({ + seeds: GAME_CONFIG.INITIAL_SEEDS, + lastSaved: Date.now(), + + updateSeeds: (amount) => + set((s) => ({ seeds: s.seeds + amount, lastSaved: Date.now() })), + + setLastSaved: (ts) => set({ lastSaved: ts }), + + hardReset: () => { + set({ + seeds: GAME_CONFIG.INITIAL_SEEDS, + inventory: [], + maxInventory: GAME_CONFIG.INITIAL_MAX_INVENTORY, + activeSlots: [null, null, null, null], + dimensionLevel: 1, + maxDimensionLevel: 1, + isDimensionActive: false, + currentEnemy: null, + upgrades: { seeds: 0, power: 0 }, + lastSaved: Date.now(), + }); + const starter = generateCard( + { COMMON: 0.8, RARE: 0.2, HOLO: 0, FULL_ART: 0 }, + 0, + ); + get().addCard(starter); + }, +}); diff --git a/src/store/slices/dimensionSlice.ts b/src/store/slices/dimensionSlice.ts new file mode 100644 index 0000000..1f3557c --- /dev/null +++ b/src/store/slices/dimensionSlice.ts @@ -0,0 +1,97 @@ +import { StateCreator } from "zustand"; +import { GameStore } from "../gameStore"; +import { GameCard } from "@/types/game"; +import { GAME_CONFIG } from "@/config/gameConfig"; +import { resolveCardStats, generateCard } from "../cardUtils"; +import { trackDimensionStart, trackDimensionEnd } from "@/lib/analytics"; + +export interface DimensionSlice { + dimensionLevel: number; + maxDimensionLevel: number; + isDimensionActive: boolean; + currentEnemy: GameCard | null; + startDimension: () => boolean; + nextDimensionLevel: () => { bonus: number; milestoneUnlocked: string | null }; + resetDimension: (reward: number) => void; +} + +export const createDimensionSlice: StateCreator< + GameStore, + [], + [], + DimensionSlice +> = (set, get) => ({ + dimensionLevel: 1, + maxDimensionLevel: 1, + isDimensionActive: false, + currentEnemy: null, + + startDimension: () => { + const { seeds } = get(); + if (seeds < GAME_CONFIG.DIMENSION_ENTRY_COST) return false; + + const enemy = generateCard( + GAME_CONFIG.CARD_GENERATION.ENEMY_WEIGHTS, + GAME_CONFIG.CARD_GENERATION.DEFAULT_COMBINE_CHANCE, + ); + const stats = resolveCardStats(enemy); + enemy.power = stats.power; + enemy.income = stats.income; + + set((s) => ({ + seeds: s.seeds - GAME_CONFIG.DIMENSION_ENTRY_COST, + isDimensionActive: true, + dimensionLevel: 1, + currentEnemy: enemy, + })); + trackDimensionStart(1); + return true; + }, + + nextDimensionLevel: () => { + const { dimensionLevel } = get(); + let bonus = 0; + let milestoneUnlocked = null; + + if ((dimensionLevel + 1) % GAME_CONFIG.DIMENSIONS.BONUS_STEP === 0) { + bonus = (dimensionLevel + 1) * GAME_CONFIG.DIMENSIONS.BONUS_AMOUNT; + } + + const nextLvl = dimensionLevel + 1; + milestoneUnlocked = GAME_CONFIG.DIMENSIONS.MILESTONES[nextLvl] || null; + + const newEnemy = generateCard( + GAME_CONFIG.CARD_GENERATION.ENEMY_WEIGHTS, + GAME_CONFIG.CARD_GENERATION.DEFAULT_COMBINE_CHANCE, + ); + const stats = resolveCardStats(newEnemy); + const scaleFactor = + 1 + (nextLvl - 1) * GAME_CONFIG.DIMENSIONS.SCALE_FACTOR; + newEnemy.power = Math.floor(stats.power * scaleFactor); + newEnemy.income = stats.income; + + set((s) => { + const nextLevel = s.dimensionLevel + 1; + const newMax = Math.max(s.maxDimensionLevel, nextLevel); + return { + seeds: s.seeds + bonus, + dimensionLevel: nextLevel, + maxDimensionLevel: newMax, + currentEnemy: newEnemy, + }; + }); + + return { bonus, milestoneUnlocked }; + }, + + resetDimension: (reward) => { + const { dimensionLevel } = get(); + trackDimensionEnd(dimensionLevel, reward); + set((s) => ({ + seeds: s.seeds + reward, + isDimensionActive: false, + dimensionLevel: 1, + currentEnemy: null, + })); + }, +}); diff --git a/src/store/slices/inventorySlice.ts b/src/store/slices/inventorySlice.ts new file mode 100644 index 0000000..30ad39f --- /dev/null +++ b/src/store/slices/inventorySlice.ts @@ -0,0 +1,97 @@ +import { StateCreator } from "zustand"; +import { GameStore } from "../gameStore"; +import { GameCard } from "@/types/game"; +import { GAME_CONFIG } from "@/config/gameConfig"; +import { resolveCardStats } from "../cardUtils"; + +export interface InventorySlice { + inventory: GameCard[]; + maxInventory: number; + activeSlots: (GameCard | null)[]; + addCard: (card: GameCard) => boolean; + addCards: (cards: GameCard[]) => boolean; + sellCard: (cardId: string, card?: GameCard) => void; + sellCards: (cardIds: string[]) => void; + toggleSlot: (slotIndex: number, card: GameCard) => void; +} + +export const createInventorySlice: StateCreator< + GameStore, + [], + [], + InventorySlice +> = (set, get) => ({ + inventory: [], + maxInventory: GAME_CONFIG.INITIAL_MAX_INVENTORY, + activeSlots: [null, null, null, null], + + addCard: (card) => { + const { inventory, maxInventory } = get(); + if (inventory.length >= maxInventory) return false; + set((s) => ({ inventory: [...s.inventory, card] })); + return true; + }, + + addCards: (cards) => { + const { inventory, maxInventory } = get(); + const availableSpace = maxInventory - inventory.length; + if (availableSpace <= 0) return false; + + const cardsToAdd = cards.slice(0, availableSpace); + set((s) => ({ inventory: [...s.inventory, ...cardsToAdd] })); + return true; + }, + + sellCard: (cardId, card) => + set((s) => { + const cardInInventory = s.inventory.find((c) => c.id === cardId); + const targetCard = cardInInventory || card; + + if (!targetCard) return s; + + const stats = resolveCardStats(targetCard); + const sellPrice = Math.floor( + stats.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER, + ); + return { + inventory: s.inventory.filter((c) => c.id !== cardId), + activeSlots: s.activeSlots.map((sl) => + sl?.id === cardId ? null : sl, + ), + seeds: s.seeds + sellPrice, + }; + }), + + sellCards: (cardIds) => + set((s) => { + const cardsToSell = s.inventory.filter((c) => cardIds.includes(c.id)); + const totalProfit = cardsToSell.reduce((acc, c) => { + const stats = resolveCardStats(c); + return ( + acc + Math.floor(stats.income * GAME_CONFIG.SELL_PRICE_MULTIPLIER) + ); + }, 0); + + return { + inventory: s.inventory.filter((c) => !cardIds.includes(c.id)), + activeSlots: s.activeSlots.map((sl) => + sl && cardIds.includes(sl.id) ? null : sl, + ), + seeds: s.seeds + totalProfit, + }; + }), + + toggleSlot: (slotIndex, card) => + set((s) => { + const slots = [...s.activeSlots]; + if (slots[slotIndex]?.id === card.id) { + slots[slotIndex] = null; + return { activeSlots: slots }; + } + const cleanedSlots = slots.map((sl) => + sl?.id === card.id ? null : sl, + ); + cleanedSlots[slotIndex] = card; + return { activeSlots: cleanedSlots }; + }), +}); diff --git a/src/store/slices/packSlice.ts b/src/store/slices/packSlice.ts new file mode 100644 index 0000000..e9d4330 --- /dev/null +++ b/src/store/slices/packSlice.ts @@ -0,0 +1,54 @@ +import { StateCreator } from "zustand"; +import { GameStore } from "../gameStore"; +import { GameCard } from "@/types/game"; +import packs from "@/data/packs.json"; +import { GAME_CONFIG } from "@/config/gameConfig"; +import { generateCard } from "../cardUtils"; +import { trackPackOpening } from "@/lib/analytics"; + +export interface PackSlice { + isPackUnlocked: (packId: string) => boolean; + buyPack: (packId: string) => GameCard[] | null; + generateRandomCard: ( + weights?: Record, + combineChance?: number, + ) => GameCard; +} + +export const createPackSlice: StateCreator = ( + set, + get, +) => ({ + isPackUnlocked: (packId) => { + const { maxDimensionLevel } = get(); + const reqLevel = GAME_CONFIG.DIMENSIONS.PACK_UNLOCKS[packId]; + if (reqLevel === undefined) return false; + return maxDimensionLevel >= reqLevel; + }, + + buyPack: (packId) => { + const { seeds, inventory, maxInventory, isPackUnlocked } = get(); + const pack = packs.find((p) => p.id === packId); + + if (!pack || seeds < pack.cost) return null; + if (inventory.length >= maxInventory) return null; + if (!isPackUnlocked(packId)) return null; + + const newCards: GameCard[] = []; + for (let i = 0; i < pack.cardCount; i++) { + newCards.push(generateCard(pack.weights, pack.combineChance)); + } + + set((state) => ({ + seeds: state.seeds - pack.cost, + })); + + trackPackOpening(pack.name, pack.cost); + + return newCards; + }, + + generateRandomCard: (weights, combineChance) => { + return generateCard(weights, combineChance); + }, +}); diff --git a/src/store/slices/upgradeSlice.ts b/src/store/slices/upgradeSlice.ts new file mode 100644 index 0000000..cc54545 --- /dev/null +++ b/src/store/slices/upgradeSlice.ts @@ -0,0 +1,59 @@ +import { StateCreator } from "zustand"; +import { GameStore } from "../gameStore"; +import { GAME_CONFIG } from "@/config/gameConfig"; +import { trackUpgrade } from "@/lib/analytics"; + +export interface UpgradeSlice { + upgrades: { + seeds: number; + power: number; + }; + buyUpgrade: (type: "seeds" | "power") => boolean; + getUpgradeCost: (type: "seeds" | "power") => number; +} + +export const createUpgradeSlice: StateCreator< + GameStore, + [], + [], + UpgradeSlice +> = (set, get) => ({ + upgrades: { + seeds: 0, + power: 0, + }, + + getUpgradeCost: (type) => { + const { upgrades } = get(); + const level = upgrades[type]; + const config = GAME_CONFIG.UPGRADES[type]; + + let cost = config.BASE_COST * Math.pow(config.COST_EXPONENT, level); + const jumps = Math.floor(level / config.JUMP_THRESHOLD); + if (jumps > 0) { + cost = cost * Math.pow(config.JUMP_MULTIPLIER, jumps); + } + + return Math.floor(cost); + }, + + buyUpgrade: (type) => { + const cost = get().getUpgradeCost(type); + const { seeds } = get(); + + if (seeds < cost) return false; + + set((s) => { + const newLevel = s.upgrades[type] + 1; + trackUpgrade(type, newLevel, cost); + return { + seeds: s.seeds - cost, + upgrades: { + ...s.upgrades, + [type]: newLevel, + }, + }; + }); + return true; + }, +}); From 3d9a60208953fc5a5a9fc7d0da28bdb238e530d4 Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:51:05 +0000 Subject: [PATCH 07/11] feat: add interdimensional storage upgrade to increase inventory capacity --- src/config/gameConfig.ts | 7 ++++++ src/pages/Upgrades.tsx | 38 +++++++++++++++++++++++++++---- src/store/slices/currencySlice.ts | 2 +- src/store/slices/upgradeSlice.ts | 17 +++++++++++--- src/types/game.ts | 1 + 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/config/gameConfig.ts b/src/config/gameConfig.ts index 9e7c0c6..0728cd3 100644 --- a/src/config/gameConfig.ts +++ b/src/config/gameConfig.ts @@ -21,6 +21,13 @@ export const GAME_CONFIG = { JUMP_THRESHOLD: 10, JUMP_MULTIPLIER: 5, BONUS_PER_LEVEL: 0.05, // 5% per level + }, + inventory: { + BASE_COST: 1500, + COST_EXPONENT: 1.8, + JUMP_THRESHOLD: 5, + JUMP_MULTIPLIER: 4, + BONUS_PER_LEVEL: 10, // 10 slots per level } }, diff --git a/src/pages/Upgrades.tsx b/src/pages/Upgrades.tsx index 68b33c0..1fa274e 100644 --- a/src/pages/Upgrades.tsx +++ b/src/pages/Upgrades.tsx @@ -9,6 +9,7 @@ import { Sword, ChevronUp, Leaf, + Layers, } from "lucide-react"; import { useGameStore } from "@/store/gameStore"; import { toast } from "sonner"; @@ -18,7 +19,7 @@ import { GAME_CONFIG } from "@/config/gameConfig"; const Upgrades = () => { const { seeds, upgrades, buyUpgrade, getUpgradeCost } = useGameStore(); - const handleBuy = (type: "seeds" | "power") => { + const handleBuy = (type: "seeds" | "power" | "inventory") => { const cost = getUpgradeCost(type); if (seeds < cost) { toast.error("Not enough Mega Seeds!", { @@ -29,17 +30,35 @@ const Upgrades = () => { if (buyUpgrade(type)) { toast.success(`Upgrade Purchased!`, { - description: `${type === "seeds" ? "Seed Production" : "Combat Power"} increased.`, + description: `${ + type === "seeds" + ? "Seed Production" + : type === "power" + ? "Combat Power" + : "Inventory Capacity" + } increased.`, }); } }; - const getEffect = (type: "seeds" | "power") => { + const getEffect = (type: "seeds" | "power" | "inventory") => { const level = upgrades[type]; const bonus = GAME_CONFIG.UPGRADES[type].BONUS_PER_LEVEL; + if (type === "inventory") { + return `+${level * bonus} slots`; + } return `+${(level * bonus * 100).toFixed(0)}%`; }; + const getNextEffect = (type: "seeds" | "power" | "inventory") => { + const level = upgrades[type]; + const bonus = GAME_CONFIG.UPGRADES[type].BONUS_PER_LEVEL; + if (type === "inventory") { + return `+${(level + 1) * bonus} slots`; + } + return `+${((level + 1) * bonus * 100).toFixed(0)}%`; + }; + const upgradesList = [ { id: "seeds", @@ -58,6 +77,15 @@ const Upgrades = () => { color: "text-red-500", bgColor: "bg-red-500/10", }, + { + id: "inventory", + name: "Interdimensional Storage", + description: + "Stabilizes localized storage space to hold more character cards.", + icon: Layers, + color: "text-blue-500", + bgColor: "bg-blue-500/10", + }, ]; return ( @@ -98,7 +126,7 @@ const Upgrades = () => {
{upgradesList.map((upg) => { - const type = upg.id as "seeds" | "power"; + const type = upg.id as "seeds" | "power" | "inventory"; const cost = getUpgradeCost(type); const level = upgrades[type]; @@ -146,7 +174,7 @@ const Upgrades = () => {

- + {((level + 1) * GAME_CONFIG.UPGRADES[type].BONUS_PER_LEVEL * 100).toFixed(0)}% + {getNextEffect(type)}

diff --git a/src/store/slices/currencySlice.ts b/src/store/slices/currencySlice.ts index 7b3e947..ceda443 100644 --- a/src/store/slices/currencySlice.ts +++ b/src/store/slices/currencySlice.ts @@ -35,7 +35,7 @@ export const createCurrencySlice: StateCreator< maxDimensionLevel: 1, isDimensionActive: false, currentEnemy: null, - upgrades: { seeds: 0, power: 0 }, + upgrades: { seeds: 0, power: 0, inventory: 0 }, lastSaved: Date.now(), }); const starter = generateCard( diff --git a/src/store/slices/upgradeSlice.ts b/src/store/slices/upgradeSlice.ts index cc54545..4035ad9 100644 --- a/src/store/slices/upgradeSlice.ts +++ b/src/store/slices/upgradeSlice.ts @@ -7,9 +7,10 @@ export interface UpgradeSlice { upgrades: { seeds: number; power: number; + inventory: number; }; - buyUpgrade: (type: "seeds" | "power") => boolean; - getUpgradeCost: (type: "seeds" | "power") => number; + buyUpgrade: (type: "seeds" | "power" | "inventory") => boolean; + getUpgradeCost: (type: "seeds" | "power" | "inventory") => number; } export const createUpgradeSlice: StateCreator< @@ -21,6 +22,7 @@ export const createUpgradeSlice: StateCreator< upgrades: { seeds: 0, power: 0, + inventory: 0, }, getUpgradeCost: (type) => { @@ -46,13 +48,22 @@ export const createUpgradeSlice: StateCreator< set((s) => { const newLevel = s.upgrades[type] + 1; trackUpgrade(type, newLevel, cost); - return { + + const newState: Partial = { seeds: s.seeds - cost, upgrades: { ...s.upgrades, [type]: newLevel, }, }; + + if (type === "inventory") { + newState.maxInventory = + GAME_CONFIG.INITIAL_MAX_INVENTORY + + newLevel * GAME_CONFIG.UPGRADES.inventory.BONUS_PER_LEVEL; + } + + return newState; }); return true; }, diff --git a/src/types/game.ts b/src/types/game.ts index e609139..2df03e6 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -42,6 +42,7 @@ export interface GameState { upgrades: { seeds: number; power: number; + inventory: number; }; lastSaved: number; } From a084b8411f2482dc696d77e29375fe216d2be09f Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:04:54 +0000 Subject: [PATCH 08/11] fix: restore income calculation and add store migration for inventory upgrade --- src/App.tsx | 4 ++-- src/components/game/Header.tsx | 4 ++-- src/config/gameConfig.ts | 21 --------------------- src/store/cardUtils.ts | 31 ++++++++++++++++++++++++++++++- src/store/gameStore.ts | 18 +++++++++++++++--- src/store/slices/upgradeSlice.ts | 2 +- 6 files changed, 50 insertions(+), 30 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ad44f5c..d79452a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,10 +12,10 @@ import Upgrades from "./pages/Upgrades"; import NotFound from "./pages/NotFound"; import { useEffect, useRef } from "react"; -import { useGameStore } from "@/store/gameStore"; +import { useGameStore, calculateCurrentIncome } from "@/store/gameStore"; import { toast } from "sonner"; import { formatCurrency } from "@/lib/utils"; -import { GAME_CONFIG, calculateCurrentIncome } from "@/config/gameConfig"; +import { GAME_CONFIG } from "@/config/gameConfig"; import { initGA, trackPageView } from "@/lib/analytics"; import { useLocation } from "react-router-dom"; diff --git a/src/components/game/Header.tsx b/src/components/game/Header.tsx index ba9ab6f..2767d9e 100644 --- a/src/components/game/Header.tsx +++ b/src/components/game/Header.tsx @@ -1,9 +1,9 @@ -import { useGameStore } from '@/store/gameStore'; +import { useGameStore, calculateCurrentIncome } from '@/store/gameStore'; import { Leaf, TrendingUp, Settings, Beaker, Library } from 'lucide-react'; import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { formatNumber, formatCurrency } from '@/lib/utils'; -import { GAME_CONFIG, calculateCurrentIncome } from '@/config/gameConfig'; +import { GAME_CONFIG } from '@/config/gameConfig'; export function Header() { const seeds = useGameStore((s) => s.seeds); diff --git a/src/config/gameConfig.ts b/src/config/gameConfig.ts index 0728cd3..62f9092 100644 --- a/src/config/gameConfig.ts +++ b/src/config/gameConfig.ts @@ -74,24 +74,3 @@ export const GAME_CONFIG = { } }; -/** - * Calculates the current income per second based on the game state. - * @param state The current game state - * @returns Income per second - */ -export const calculateCurrentIncome = (state: Pick) => { - const activeIncome = state.activeSlots.reduce( - (sum: number, slot: GameCard | null) => sum + (slot?.income ?? 0), - 0, - ); - - const inactiveCards = state.inventory.filter( - (c: GameCard) => !state.activeSlots.some((s: GameCard | null) => s?.id === c.id), - ).length; - - const bonus = 1 + inactiveCards * GAME_CONFIG.INCOME.INACTIVE_CARD_BONUS; - - const upgradeBonus = 1 + state.upgrades.seeds * GAME_CONFIG.UPGRADES.seeds.BONUS_PER_LEVEL; - - return activeIncome * bonus * upgradeBonus; -}; diff --git a/src/store/cardUtils.ts b/src/store/cardUtils.ts index f4f586a..c6731b9 100644 --- a/src/store/cardUtils.ts +++ b/src/store/cardUtils.ts @@ -1,4 +1,4 @@ -import { Character, GameCard, CardType } from "@/types/game"; +import { Character, GameCard, CardType, GameState } from "@/types/game"; import characters from "@/data/characters.json"; import cardTypes from "@/data/cardTypes.json"; import { GAME_CONFIG } from "@/config/gameConfig"; @@ -84,3 +84,32 @@ export const resolveCardStats = (card: GameCard) => { character, }; }; + +/** + * Calculates the current income per second based on the game state. + * @param state The current game state + * @returns Income per second + */ +export const calculateCurrentIncome = ( + state: Pick, +) => { + const activeIncome = state.activeSlots.reduce( + (sum: number, slot: GameCard | null) => { + if (!slot) return sum; + return sum + resolveCardStats(slot).income; + }, + 0, + ); + + const inactiveCards = state.inventory.filter( + (c: GameCard) => + !state.activeSlots.some((s: GameCard | null) => s?.id === c.id), + ).length; + + const bonus = 1 + inactiveCards * GAME_CONFIG.INCOME.INACTIVE_CARD_BONUS; + + const upgradeBonus = + 1 + state.upgrades.seeds * GAME_CONFIG.UPGRADES.seeds.BONUS_PER_LEVEL; + + return activeIncome * bonus * upgradeBonus; +}; diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 6ce6b62..595ce88 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -11,7 +11,7 @@ import { createUpgradeSlice, UpgradeSlice } from "./slices/upgradeSlice"; import { createPackSlice, PackSlice } from "./slices/packSlice"; // Re-export utilities for component usage -export { resolveCardStats } from "./cardUtils"; +export { resolveCardStats, calculateCurrentIncome } from "./cardUtils"; export type GameStore = CurrencySlice & InventorySlice & @@ -30,7 +30,7 @@ export const useGameStore = create()( }), { name: "rick-morty-idle-save", - version: 5, + version: 6, migrate: (persistedState: unknown, version: number) => { let state = persistedState as any; @@ -39,7 +39,7 @@ export const useGameStore = create()( ...state, inventory: [], activeSlots: [null, null, null, null], - upgrades: { seeds: 0, power: 0 }, + upgrades: { seeds: 0, power: 0, inventory: 0 }, }; } @@ -134,6 +134,18 @@ export const useGameStore = create()( }; } + if (version < 6) { + state = { + ...state, + upgrades: { + ...state.upgrades, + inventory: state.upgrades?.inventory ?? 0, + }, + maxInventory: + state.maxInventory ?? 50, + }; + } + return state; }, onRehydrateStorage: () => (state: GameStore | undefined) => { diff --git a/src/store/slices/upgradeSlice.ts b/src/store/slices/upgradeSlice.ts index 4035ad9..2a90093 100644 --- a/src/store/slices/upgradeSlice.ts +++ b/src/store/slices/upgradeSlice.ts @@ -48,7 +48,7 @@ export const createUpgradeSlice: StateCreator< set((s) => { const newLevel = s.upgrades[type] + 1; trackUpgrade(type, newLevel, cost); - + const newState: Partial = { seeds: s.seeds - cost, upgrades: { From f52660f9ffb6928d42b6156b2bf2e7f0911a777f Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:42:43 +0000 Subject: [PATCH 09/11] perf: optimize inactive card counting and ensure income precision --- src/components/game/GameCard.tsx | 30 +++++++++++++++++++++++------- src/components/game/Header.tsx | 5 ++--- src/components/game/PortalArea.tsx | 5 ++--- src/config/gameConfig.ts | 21 ++++++++++----------- src/data/characters.json | 2 +- src/store/cardUtils.ts | 9 ++++----- 6 files changed, 42 insertions(+), 30 deletions(-) diff --git a/src/components/game/GameCard.tsx b/src/components/game/GameCard.tsx index db63d6c..1d3adc7 100644 --- a/src/components/game/GameCard.tsx +++ b/src/components/game/GameCard.tsx @@ -8,6 +8,8 @@ import { Trophy, RefreshCw, Sword, + Leaf, + LucideIcon, } from "lucide-react"; import { formatNumber } from "@/lib/utils"; import { resolveCardStats } from "@/store/gameStore"; @@ -18,7 +20,18 @@ interface GameCardProps { isActive?: boolean; } -const typeIcons: Record = { +type RarityOrType = + | "COMMON" + | "NORMAL" + | "RARE" + | "HOLO" + | "FULL_ART" + | "SILVER" + | "GOLD" + | "REVERT" + | "SWORD"; + +const typeIcons = { COMMON: Circle, NORMAL: Circle, RARE: Star, @@ -28,9 +41,9 @@ const typeIcons: Record = { GOLD: Trophy, REVERT: RefreshCw, SWORD: Sword, -}; +} satisfies Record; -const rarityConfig: Record = { +const rarityConfig = { COMMON: { border: "border-border", bg: "bg-card" }, NORMAL: { border: "border-border", bg: "bg-card" }, RARE: { border: "border-blue-400/60 animate-rare-pulse", bg: "bg-card" }, @@ -39,7 +52,7 @@ const rarityConfig: Record = { SILVER: { border: "border-slate-300", bg: "bg-slate-900/40" }, GOLD: { border: "border-yellow-500", bg: "bg-yellow-900/20" }, REVERT: { border: "border-red-500", bg: "bg-black" }, -}; +} satisfies Record; export function GameCard({ card, onClick, isActive }: GameCardProps) { const stats = resolveCardStats(card); @@ -156,9 +169,12 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) { })}
- - +{formatNumber(income)}/s - +
+ + + {formatNumber(income)}/s + +
diff --git a/src/components/game/Header.tsx b/src/components/game/Header.tsx index 2767d9e..ef41138 100644 --- a/src/components/game/Header.tsx +++ b/src/components/game/Header.tsx @@ -11,9 +11,8 @@ export function Header() { const inventory = useGameStore((s) => s.inventory); const upgrades = useGameStore((s) => s.upgrades); - const inactiveCards = inventory.filter( - (c) => !activeSlots.some((s) => s?.id === c.id) - ).length; + const activeCount = activeSlots.filter(Boolean).length; + const inactiveCards = Math.max(0, inventory.length - activeCount); const collectionBonus = Math.round(inactiveCards * GAME_CONFIG.INCOME.INACTIVE_CARD_BONUS * 100); const labBonus = Math.round((upgrades.seeds || 0) * GAME_CONFIG.UPGRADES.seeds.BONUS_PER_LEVEL * 100); diff --git a/src/components/game/PortalArea.tsx b/src/components/game/PortalArea.tsx index 7036f36..c01e2fb 100644 --- a/src/components/game/PortalArea.tsx +++ b/src/components/game/PortalArea.tsx @@ -7,9 +7,8 @@ export function PortalArea() { const inventory = useGameStore((s) => s.inventory); const toggleSlot = useGameStore((s) => s.toggleSlot); - const inactiveCards = inventory.filter( - (c) => !activeSlots.some((s) => s?.id === c.id), - ).length; + const activeCount = activeSlots.filter(Boolean).length; + const inactiveCards = Math.max(0, Math.floor((inventory.length - activeCount) / 2)); return (
diff --git a/src/config/gameConfig.ts b/src/config/gameConfig.ts index 62f9092..c6d8512 100644 --- a/src/config/gameConfig.ts +++ b/src/config/gameConfig.ts @@ -6,7 +6,7 @@ export const GAME_CONFIG = { DIMENSION_ENTRY_COST: 1000, SELL_PRICE_MULTIPLIER: 100, MAX_OFFLINE_SECONDS: 24 * 60 * 60, // 24h - + UPGRADES: { seeds: { BASE_COST: 500, @@ -25,14 +25,14 @@ export const GAME_CONFIG = { inventory: { BASE_COST: 1500, COST_EXPONENT: 1.8, - JUMP_THRESHOLD: 5, + JUMP_THRESHOLD: 10, JUMP_MULTIPLIER: 4, - BONUS_PER_LEVEL: 10, // 10 slots per level - } + BONUS_PER_LEVEL: 4, // 10 slots per level + }, }, INCOME: { - INACTIVE_CARD_BONUS: 0.01, // 1% per inactive card + INACTIVE_CARD_BONUS: 0.005, // 1% per inactive card }, CARD_GENERATION: { @@ -48,7 +48,7 @@ export const GAME_CONFIG = { RARE: 0.3, HOLO: 0.08, FULL_ART: 0.02, - } + }, }, DIMENSIONS: { @@ -65,12 +65,11 @@ export const GAME_CONFIG = { 100: "Void Breach", } as Record, PACK_UNLOCKS: { - "standard": 0, - "mega": 10, + standard: 0, + mega: 10, "silver-rift": 25, "alchemists-portal": 50, "void-breach": 100, - } as Record - } + } as Record, + }, }; - diff --git a/src/data/characters.json b/src/data/characters.json index 2f9a87b..766bee5 100644 --- a/src/data/characters.json +++ b/src/data/characters.json @@ -243,7 +243,7 @@ "gender": "Male", "origin": "unknown", "location": "unknown", - "avatarId": 19, + "customImage": "https://static.wikia.nocookie.net/rickandmorty/images/4/49/Antenna_Rick.png/revision/latest?cb=20161121231006", "baseMultiplier": 450, "basePower": 350 }, diff --git a/src/store/cardUtils.ts b/src/store/cardUtils.ts index c6731b9..4c5b400 100644 --- a/src/store/cardUtils.ts +++ b/src/store/cardUtils.ts @@ -101,15 +101,14 @@ export const calculateCurrentIncome = ( 0, ); - const inactiveCards = state.inventory.filter( - (c: GameCard) => - !state.activeSlots.some((s: GameCard | null) => s?.id === c.id), - ).length; + // Optimized: Instead of filtering, use simple subtraction ($O(1)$ -> $O(N)$) + const activeCount = state.activeSlots.filter(Boolean).length; + const inactiveCards = Math.max(0, state.inventory.length - activeCount); const bonus = 1 + inactiveCards * GAME_CONFIG.INCOME.INACTIVE_CARD_BONUS; const upgradeBonus = 1 + state.upgrades.seeds * GAME_CONFIG.UPGRADES.seeds.BONUS_PER_LEVEL; - return activeIncome * bonus * upgradeBonus; + return Math.floor(activeIncome * bonus * upgradeBonus); }; From e22a721a8cff1fa2f6b04568a1868ae33f95c760 Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:00:59 +0000 Subject: [PATCH 10/11] perf: optimize character lookup using Map for O(1) complexity --- src/config/gameConfig.ts | 2 +- src/store/cardUtils.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/config/gameConfig.ts b/src/config/gameConfig.ts index c6d8512..ee57965 100644 --- a/src/config/gameConfig.ts +++ b/src/config/gameConfig.ts @@ -4,7 +4,7 @@ export const GAME_CONFIG = { INITIAL_SEEDS: 100, INITIAL_MAX_INVENTORY: 50, DIMENSION_ENTRY_COST: 1000, - SELL_PRICE_MULTIPLIER: 100, + SELL_PRICE_MULTIPLIER: 0.8, MAX_OFFLINE_SECONDS: 24 * 60 * 60, // 24h UPGRADES: { diff --git a/src/store/cardUtils.ts b/src/store/cardUtils.ts index 4c5b400..1893242 100644 --- a/src/store/cardUtils.ts +++ b/src/store/cardUtils.ts @@ -48,6 +48,8 @@ export const generateCard = ( }; }; +const characterMap = new Map(characters.map((c) => [c.id, c])); + export const resolveCardStats = (card: GameCard) => { if (!card) { const character = (characters as Character[])[0]; @@ -59,8 +61,9 @@ export const resolveCardStats = (card: GameCard) => { } const character = - (characters as Character[]).find((c) => c.id === card.characterId) || - (characters as Character[])[0]; + characterMap.get(card.characterId) ?? (characters as Character[])[0]; + // (characters as Character[]).find((c) => c.id === card.characterId) || + // (characters as Character[])[0]; const types = card.types || []; @@ -75,8 +78,7 @@ export const resolveCardStats = (card: GameCard) => { const baseIncome = card.income !== undefined ? card.income : character.baseMultiplier; - const basePower = - card.power !== undefined ? card.power : character.basePower; + const basePower = card.power !== undefined ? card.power : character.basePower; return { income: Math.floor(baseIncome * combinedMultiplier), From a4608520ef136539117feec823deab47a9d9b5f4 Mon Sep 17 00:00:00 2001 From: Jakub Buciuto <46843555+MrJacob12@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:04:33 +0000 Subject: [PATCH 11/11] refactor: decouple migration logic from gameStore into migration.ts --- src/components/game/PortalArea.tsx | 8 +- src/store/gameStore.ts | 121 +---------------------------- src/store/migrations.ts | 120 ++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 122 deletions(-) create mode 100644 src/store/migrations.ts diff --git a/src/components/game/PortalArea.tsx b/src/components/game/PortalArea.tsx index c01e2fb..fd8ea96 100644 --- a/src/components/game/PortalArea.tsx +++ b/src/components/game/PortalArea.tsx @@ -8,7 +8,7 @@ export function PortalArea() { const toggleSlot = useGameStore((s) => s.toggleSlot); const activeCount = activeSlots.filter(Boolean).length; - const inactiveCards = Math.max(0, Math.floor((inventory.length - activeCount) / 2)); + const inactiveCards = Math.max(0, (inventory.length - activeCount)); return (
@@ -18,8 +18,10 @@ export function PortalArea() {

Place cards to generate Mega Seeds • Collection bonus:{" "} - +{inactiveCards}% ( - {inactiveCards} cards in inventory) + + +{Math.max(0, Math.floor((inventory.length - activeCount) / 2))}% + {" "} + ({inactiveCards} cards in inventory)

diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 595ce88..1e899c9 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -1,7 +1,5 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { Character, GameCard } from "@/types/game"; -import characters from "@/data/characters.json"; import { generateCard } from "./cardUtils"; import { createCurrencySlice, CurrencySlice } from "./slices/currencySlice"; @@ -9,6 +7,7 @@ import { createInventorySlice, InventorySlice } from "./slices/inventorySlice"; import { createDimensionSlice, DimensionSlice } from "./slices/dimensionSlice"; import { createUpgradeSlice, UpgradeSlice } from "./slices/upgradeSlice"; import { createPackSlice, PackSlice } from "./slices/packSlice"; +import { migrateGameStore } from "./migrations"; // Re-export utilities for component usage export { resolveCardStats, calculateCurrentIncome } from "./cardUtils"; @@ -31,123 +30,7 @@ export const useGameStore = create()( { name: "rick-morty-idle-save", version: 6, - migrate: (persistedState: unknown, version: number) => { - let state = persistedState as any; - - if (version === 0) { - state = { - ...state, - inventory: [], - activeSlots: [null, null, null, null], - upgrades: { seeds: 0, power: 0, inventory: 0 }, - }; - } - - if (version < 2) { - const migrateCard = (card: any): any => { - if (!card) return card; - if (!card.origin || card.origin === "none") { - const charData = (characters as Character[]).find( - (c) => c.name === card.characterName, - ); - return { - ...card, - origin: charData?.origin || "unknown", - location: charData?.location || "unknown", - }; - } - return card; - }; - - state = { - ...state, - inventory: state.inventory?.map(migrateCard) || [], - activeSlots: - state.activeSlots?.map((slot: any) => - slot ? migrateCard(slot) : null, - ) || [null, null, null, null], - }; - } - - if (version < 3) { - const oldInv = state.inventory || []; - const newInventory = oldInv.map((card: any) => { - const uuid = crypto.randomUUID().slice(0, 8); - const baseId = card.id - ? card.id.split("-").slice(0, 2).join("-") - : "unknown"; - return { - ...card, - id: `${baseId}-${card.timestamp || Date.now()}-${uuid}`, - }; - }); - - const newActiveSlots = ( - state.activeSlots || [null, null, null, null] - ).map((slot: any) => { - if (!slot) return null; - const oldIndex = oldInv.findIndex((c: any) => c.id === slot.id); - if (oldIndex !== -1 && newInventory[oldIndex]) { - return newInventory[oldIndex]; - } - return null; - }); - - state = { - ...state, - inventory: newInventory, - activeSlots: newActiveSlots, - }; - } - - if (version < 4) { - state = { - ...state, - currentEnemy: null, - }; - } - - if (version < 5) { - const migrateCardV5 = (card: any): GameCard | null => { - if (!card) return null; - if (card.characterId) return card; - const charData = (characters as Character[]).find( - (c) => c.name === (card.characterName || card.name), - ); - return { - id: card.id, - characterId: charData?.id || 1, - types: card.types || ["COMMON"], - timestamp: card.timestamp || Date.now(), - income: card.income, - power: card.power, - }; - }; - - state = { - ...state, - inventory: state.inventory?.map(migrateCardV5).filter(Boolean) || [], - activeSlots: - state.activeSlots?.map((slot: any) => - slot ? migrateCardV5(slot) : null, - ) || [null, null, null, null], - }; - } - - if (version < 6) { - state = { - ...state, - upgrades: { - ...state.upgrades, - inventory: state.upgrades?.inventory ?? 0, - }, - maxInventory: - state.maxInventory ?? 50, - }; - } - - return state; - }, + migrate: migrateGameStore, onRehydrateStorage: () => (state: GameStore | undefined) => { if (state && state.inventory.length === 0) { const starter = generateCard( diff --git a/src/store/migrations.ts b/src/store/migrations.ts new file mode 100644 index 0000000..adb238e --- /dev/null +++ b/src/store/migrations.ts @@ -0,0 +1,120 @@ +import { Character, GameCard } from "@/types/game"; +import characters from "@/data/characters.json"; + +export const migrateGameStore = (persistedState: unknown, version: number) => { + let state = persistedState as any; + + if (version === 0) { + state = { + ...state, + inventory: [], + activeSlots: [null, null, null, null], + upgrades: { seeds: 0, power: 0, inventory: 0 }, + }; + } + + if (version < 2) { + const migrateCard = (card: any): any => { + if (!card) return card; + if (!card.origin || card.origin === "none") { + const charData = (characters as Character[]).find( + (c) => c.name === card.characterName, + ); + return { + ...card, + origin: charData?.origin || "unknown", + location: charData?.location || "unknown", + }; + } + return card; + }; + + state = { + ...state, + inventory: state.inventory?.map(migrateCard) || [], + activeSlots: + state.activeSlots?.map((slot: any) => + slot ? migrateCard(slot) : null, + ) || [null, null, null, null], + }; + } + + if (version < 3) { + const oldInv = state.inventory || []; + const newInventory = oldInv.map((card: any) => { + const uuid = crypto.randomUUID().slice(0, 8); + const baseId = card.id + ? card.id.split("-").slice(0, 2).join("-") + : "unknown"; + return { + ...card, + id: `${baseId}-${card.timestamp || Date.now()}-${uuid}`, + }; + }); + + const newActiveSlots = ( + state.activeSlots || [null, null, null, null] + ).map((slot: any) => { + if (!slot) return null; + const oldIndex = oldInv.findIndex((c: any) => c.id === slot.id); + if (oldIndex !== -1 && newInventory[oldIndex]) { + return newInventory[oldIndex]; + } + return null; + }); + + state = { + ...state, + inventory: newInventory, + activeSlots: newActiveSlots, + }; + } + + if (version < 4) { + state = { + ...state, + currentEnemy: null, + }; + } + + if (version < 5) { + const migrateCardV5 = (card: any): GameCard | null => { + if (!card) return null; + if (card.characterId) return card; + const charData = (characters as Character[]).find( + (c) => c.name === (card.characterName || card.name), + ); + return { + id: card.id, + characterId: charData?.id || 1, + types: card.types || ["COMMON"], + timestamp: card.timestamp || Date.now(), + income: card.income, + power: card.power, + }; + }; + + state = { + ...state, + inventory: state.inventory?.map(migrateCardV5).filter(Boolean) || [], + activeSlots: + state.activeSlots?.map((slot: any) => + slot ? migrateCardV5(slot) : null, + ) || [null, null, null, null], + }; + } + + if (version < 6) { + state = { + ...state, + upgrades: { + ...state.upgrades, + inventory: state.upgrades?.inventory ?? 0, + }, + maxInventory: + state.maxInventory ?? 50, + }; + } + + return state; +};