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 }} 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/CollectionTab.tsx b/src/components/game/CollectionTab.tsx index 6ed34de..44becf3 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,12 @@ export function CollectionTab() { const displayCards = useMemo(() => { return inventory - .filter(card => - card.name.toLowerCase().includes(search.toLowerCase()) || - card.characterName.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.income - a.income) + .sort((a, b) => b.stats.income - a.stats.income) .slice(0, 4); }, [inventory, search]); @@ -62,7 +63,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..1d3adc7 100644 --- a/src/components/game/GameCard.tsx +++ b/src/components/game/GameCard.tsx @@ -8,9 +8,11 @@ import { Trophy, RefreshCw, Sword, + Leaf, + LucideIcon, } from "lucide-react"; import { formatNumber } from "@/lib/utils"; -import cardTypes from "@/data/cardTypes.json"; +import { resolveCardStats } from "@/store/gameStore"; interface GameCardProps { card: GameCardType; @@ -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,15 +52,17 @@ 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); + 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 isSilver = types.includes("SILVER"); const isRevert = types.includes("REVERT"); const primaryType = types.includes("REVERT") @@ -68,12 +83,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 +119,7 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) { {isFullArt ? ( {card.characterName} @@ -114,7 +127,7 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) {
{card.characterName} @@ -124,14 +137,14 @@ export function GameCard({ card, onClick, isActive }: GameCardProps) {
- {card.status} + {character.status}
@@ -140,10 +153,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}

@@ -156,19 +169,24 @@ 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/Header.tsx b/src/components/game/Header.tsx index ba9ab6f..ef41138 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); @@ -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/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/components/game/PortalArea.tsx b/src/components/game/PortalArea.tsx index 7036f36..fd8ea96 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, (inventory.length - activeCount)); return (
@@ -19,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/config/gameConfig.ts b/src/config/gameConfig.ts index 9e7c0c6..ee57965 100644 --- a/src/config/gameConfig.ts +++ b/src/config/gameConfig.ts @@ -4,9 +4,9 @@ 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: { seeds: { BASE_COST: 500, @@ -21,11 +21,18 @@ 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: 10, + JUMP_MULTIPLIER: 4, + 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: { @@ -41,7 +48,7 @@ export const GAME_CONFIG = { RARE: 0.3, HOLO: 0.08, FULL_ART: 0.02, - } + }, }, DIMENSIONS: { @@ -58,33 +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 - } -}; - -/** - * 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; + } as Record, + }, }; diff --git a/src/data/characters.json b/src/data/characters.json index 4268598..766bee5 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": 125 + }, + { + "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": 135 + }, + { + "id": 19, + "name": "Antenna Rick", + "status": "unknown", + "species": "Human", + "type": "Human with antennae", + "gender": "Male", + "origin": "unknown", + "location": "unknown", + "customImage": "https://static.wikia.nocookie.net/rickandmorty/images/4/49/Antenna_Rick.png/revision/latest?cb=20161121231006", + "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" } ] } diff --git a/src/pages/Collection.tsx b/src/pages/Collection.tsx index d231716..234fec2 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,20 @@ const Collection = () => { const filteredCards = useMemo(() => { return inventory - .filter((card) => { - const matchesSearch = card.name.toLowerCase().includes(search.toLowerCase()) || - card.characterName.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.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 +61,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 +159,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/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/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/cardUtils.ts b/src/store/cardUtils.ts new file mode 100644 index 0000000..1893242 --- /dev/null +++ b/src/store/cardUtils.ts @@ -0,0 +1,116 @@ +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"; + +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, + }; +}; + +const characterMap = new Map(characters.map((c) => [c.id, c])); + +export const resolveCardStats = (card: GameCard) => { + if (!card) { + const character = (characters as Character[])[0]; + return { + income: 0, + power: 0, + character, + }; + } + + const character = + 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 || []; + + 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, + }; +}; + +/** + * 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, + ); + + // 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 Math.floor(activeIncome * bonus * upgradeBonus); +}; diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 92940d4..1e899c9 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -1,432 +1,37 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -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"; +import { migrateGameStore } from "./migrations"; -type PersistedGameState = GameState; +// Re-export utilities for component usage +export { resolveCardStats, calculateCurrentIncome } 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 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, - 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 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 sellPrice = Math.floor( - targetCard.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) => - acc + Math.floor(c.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 scaleFactor = - 1 + (nextLvl - 1) * GAME_CONFIG.DIMENSIONS.SCALE_FACTOR; - newEnemy.power = Math.floor(newEnemy.power * scaleFactor); - - 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", - version: 4, - migrate: (persistedState: unknown, version: number) => { - let state = persistedState as PersistedGameState; - - if (version === 0) { - state = { - ...state, - inventory: [], - activeSlots: [null, null, null, null], - upgrades: { seeds: 0, power: 0 }, - }; - } - - if (version < 2) { - const migrateCard = (card: GameCard): GameCard => { - 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) => - 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: GameCard) => { - 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) => { - if (!slot) return null; - const oldIndex = oldInv.findIndex( - (c: GameCard) => 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, - }; - } - - return state; - }, - onRehydrateStorage: () => (state: GameState | undefined) => { + version: 6, + migrate: migrateGameStore, + 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/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; +}; diff --git a/src/store/slices/currencySlice.ts b/src/store/slices/currencySlice.ts new file mode 100644 index 0000000..ceda443 --- /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, inventory: 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..2a90093 --- /dev/null +++ b/src/store/slices/upgradeSlice.ts @@ -0,0 +1,70 @@ +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; + inventory: number; + }; + buyUpgrade: (type: "seeds" | "power" | "inventory") => boolean; + getUpgradeCost: (type: "seeds" | "power" | "inventory") => number; +} + +export const createUpgradeSlice: StateCreator< + GameStore, + [], + [], + UpgradeSlice +> = (set, get) => ({ + upgrades: { + seeds: 0, + power: 0, + inventory: 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); + + 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 bd290b0..2df03e6 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 { @@ -49,6 +42,7 @@ export interface GameState { upgrades: { seeds: number; power: number; + inventory: number; }; lastSaved: number; }