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 ? (
@@ -114,7 +113,7 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) {

@@ -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;
+};