From 625b9d1170fe0884f72399a383f15e770159043b Mon Sep 17 00:00:00 2001 From: bal7hazar Date: Fri, 24 Jan 2025 17:34:10 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20achievements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/.env.development | 2 +- client/src/components/achievements/index.tsx | 122 +++++ .../components/achievements/leaderboard.tsx | 159 +++++++ client/src/components/achievements/tab.tsx | 122 +++++ .../src/components/achievements/trophies.tsx | 330 +++++++++++++ client/src/components/achievements/trophy.tsx | 442 ++++++++++++++++++ client/src/components/app.tsx | 28 +- .../components/{games.tsx => games/index.tsx} | 33 +- client/src/components/scenes/achievement.tsx | 5 + client/src/components/scenes/inventory.tsx | 23 +- client/src/context/project.tsx | 22 +- client/src/hooks/achievements.ts | 87 +--- client/src/hooks/progressions.ts | 23 +- client/src/hooks/project.ts | 5 +- client/src/hooks/trophies.ts | 24 +- contracts/dojo_mainnet.toml | 2 + contracts/manifest_mainnet.json | 6 +- contracts/scripts/darkshuffle.sh | 21 + contracts/scripts/remove.sh | 9 + contracts/scripts/seeding_mainnet.sh | 23 +- contracts/scripts/seeding_sepolia.sh | 2 +- contracts/scripts/update.sh | 21 + contracts/scripts/update_dopewars.sh | 19 - contracts/src/systems/registry.cairo | 3 + .../src/components/registerable.cairo | 3 +- packages/registry/src/models/game.cairo | 11 +- .../registry/src/tests/mocks/register.cairo | 3 + .../src/tests/test_registerable.cairo | 1 + 28 files changed, 1416 insertions(+), 135 deletions(-) create mode 100644 client/src/components/achievements/index.tsx create mode 100644 client/src/components/achievements/leaderboard.tsx create mode 100644 client/src/components/achievements/tab.tsx create mode 100644 client/src/components/achievements/trophies.tsx create mode 100644 client/src/components/achievements/trophy.tsx rename client/src/components/{games.tsx => games/index.tsx} (78%) create mode 100644 client/src/components/scenes/achievement.tsx create mode 100755 contracts/scripts/darkshuffle.sh create mode 100755 contracts/scripts/remove.sh create mode 100755 contracts/scripts/update.sh delete mode 100755 contracts/scripts/update_dopewars.sh diff --git a/client/.env.development b/client/.env.development index 78112b2..fc5c6e9 100644 --- a/client/.env.development +++ b/client/.env.development @@ -1,3 +1,3 @@ -VITE_CARTRIDGE_API_URL="https://api.cartridge.gg/x/cartridge" +VITE_CARTRIDGE_API_URL="https://api.cartridge.gg" VITE_RPC_URL="https://api.cartridge.gg/x/starknet/mainnet" VITE_TORII_URL="https://api.cartridge.gg/x/arcade/torii" diff --git a/client/src/components/achievements/index.tsx b/client/src/components/achievements/index.tsx new file mode 100644 index 0000000..ca91746 --- /dev/null +++ b/client/src/components/achievements/index.tsx @@ -0,0 +1,122 @@ +import { Spinner } from "@cartridge/ui-next"; +import { TrophiesTab, LeaderboardTab } from "./tab"; +import { useEffect, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { Trophies } from "./trophies"; +import { Leaderboard } from "./leaderboard"; +import { useData } from "@/hooks/context"; +import { useArcade } from "@/hooks/arcade"; +import { GameModel } from "@bal7hazar/arcade-sdk"; +import { addAddressPadding } from "starknet"; +import { useAccount } from "@starknet-react/core"; +import { useProject } from "@/hooks/project"; + +export function Achievements() { + const { address: self } = useAccount(); + const { + trophies: { achievements, players, isLoading }, + setAccountAddress, + } = useData(); + + const { pins, games } = useArcade(); + + const { address } = useParams<{ address: string }>(); + const { project, namespace } = useProject(); + + const [activeTab, setActiveTab] = useState<"trophies" | "leaderboard">( + "trophies", + ); + + const game: GameModel | undefined = useMemo(() => { + return Object.values(games).find( + (game) => game.namespace === namespace && game.project === project, + ); + }, [games, project, namespace]); + + const { pinneds, completed, total } = useMemo(() => { + const ids = pins[addAddressPadding(address || self || "0x0")] || []; + const pinneds = achievements + .filter((item) => ids.includes(item.id)) + .sort((a, b) => parseFloat(a.percentage) - parseFloat(b.percentage)) + .slice(0, 3); // There is a front-end limit of 3 pinneds + const completed = achievements.filter((item) => item.completed).length; + const total = achievements.length; + return { pinneds, completed, total }; + }, [achievements, pins, address, self]); + + const { rank, earnings } = useMemo(() => { + const rank = + players.findIndex( + (player) => + BigInt(player.address || 0) === BigInt(address || self || 0), + ) + 1; + const earnings = + players.find( + (player) => + BigInt(player.address || 0) === BigInt(address || self || 0), + )?.earnings || 0; + return { rank, earnings }; + }, [address, self, players]); + + const isSelf = useMemo(() => { + return !address || address === self; + }, [address, self]); + + useEffect(() => { + setAccountAddress(address || self || ""); + }, [address, self, setAccountAddress]); + + return ( +
+ {achievements.length ? ( +
+ {isSelf && ( +
+ setActiveTab("trophies")} + /> + setActiveTab("leaderboard")} + /> +
+ )} + {(!isSelf || activeTab === "trophies") && ( + + )} + {isSelf && activeTab === "leaderboard" && ( + + )} +
+ ) : isLoading ? ( +
+
+ +
+
+ ) : ( +
+
+

No trophies available

+
+
+ )} +
+ ); +} diff --git a/client/src/components/achievements/leaderboard.tsx b/client/src/components/achievements/leaderboard.tsx new file mode 100644 index 0000000..93bbad7 --- /dev/null +++ b/client/src/components/achievements/leaderboard.tsx @@ -0,0 +1,159 @@ +import { + cn, + ScrollArea, + SpaceInvaderIcon, + SparklesIcon, + StateIconProps, +} from "@cartridge/ui-next"; +import { Link, useLocation } from "react-router-dom"; +import { Item, Player } from "@/hooks/achievements"; +import { useUsername } from "@/hooks/account"; +import { useMemo } from "react"; +import { addAddressPadding } from "starknet"; + +export function Leaderboard({ + players, + address, + achievements, + pins, +}: { + players: Player[]; + address: string; + achievements: Item[]; + pins: { [playerId: string]: string[] }; +}) { + return ( + +
+ {players.map((player, index) => ( + + ))} +
+
+ ); +} + +function Row({ + self, + address, + earnings, + rank, + completeds, + achievements, + pins, +}: { + self: boolean; + address: string; + earnings: number; + rank: number; + completeds: string[]; + achievements: Item[]; + pins: { [playerId: string]: string[] }; +}) { + const { username } = useUsername({ address }); + const location = useLocation(); + + const path = useMemo(() => { + if (self) return location.pathname; + return [...location.pathname.split("/"), address].join("/"); + }, [location.pathname, address, self]); + + const trophies = useMemo(() => { + const ids = (address ? pins[addAddressPadding(address)] : []) || []; + const pinneds = achievements + .filter((achievement) => completeds.includes(achievement.id)) + .filter((item) => ids.includes(item.id)) + .sort((a, b) => parseFloat(a.percentage) - parseFloat(b.percentage)) + .slice(0, 3); // There is a front-end limit of 3 pinneds + return pinneds + .filter((achievement) => achievement) + .map((achievement) => ({ + address, + id: achievement.id, + icon: achievement.icon, + })); + }, [achievements, completeds, address, pins]); + + return ( + +
+
+
+

{`${rank}.`}

+ +
+ +
+ +
+ + ); +} + +function User({ + username, + Icon, +}: { + username: string; + Icon: React.ComponentType; +}) { + return ( +
+ +

{username}

+
+ ); +} + +function Trophies({ + self, + trophies, +}: { + self: boolean; + trophies: { address: string; id: string; icon: string }[]; +}) { + return ( +
+ {trophies.map((trophy) => ( +
+
+
+ ))} +
+ ); +} + +function Earnings({ earnings, self }: { earnings: number; self: boolean }) { + return ( +
+ +

{earnings}

+
+ ); +} diff --git a/client/src/components/achievements/tab.tsx b/client/src/components/achievements/tab.tsx new file mode 100644 index 0000000..a2d8430 --- /dev/null +++ b/client/src/components/achievements/tab.tsx @@ -0,0 +1,122 @@ +import { + cn, + LeaderboardIcon, + SparklesIcon, + StateIconProps, + TrophyIcon, +} from "@cartridge/ui-next"; +import { useState } from "react"; + +export function TrophiesTab({ + active, + completed, + total, + onClick, +}: { + active: boolean; + completed: number; + total: number; + onClick: () => void; +}) { + return ( + + +

+ {`${completed}/${total}`} +

+
+ ); +} + +export function LeaderboardTab({ + active, + rank, + earnings, + onClick, +}: { + active: boolean; + rank: number; + earnings: number; + onClick: () => void; +}) { + return ( + + + + + ); +} + +export function Scoreboard({ + rank, + earnings, +}: { + rank: number; + earnings: number; +}) { + return ( +
+
+ +
+
+ +
+
+ ); +} + +export function Tab({ + active, + priority, + onClick, + children, +}: { + active: boolean; + priority: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + const [hovered, setHovered] = useState(false); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {children} +
+ ); +} + +export function Item({ + Icon, + active, + label, +}: { + Icon: React.ComponentType; + active: boolean; + label: string; +}) { + return ( +
+ +

{label}

+
+ ); +} diff --git a/client/src/components/achievements/trophies.tsx b/client/src/components/achievements/trophies.tsx new file mode 100644 index 0000000..e38de3d --- /dev/null +++ b/client/src/components/achievements/trophies.tsx @@ -0,0 +1,330 @@ +import { JoystickIcon, ScrollArea, WedgeIcon, cn } from "@cartridge/ui-next"; +import { Trophy } from "./trophy"; +import { Item } from "@/hooks/achievements"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { GameModel } from "@bal7hazar/arcade-sdk"; + +const HIDDEN_GROUP = "HIDDEN"; + +export function Trophies({ + achievements, + softview, + enabled, + game, + pins, +}: { + achievements: Item[]; + softview: boolean; + enabled: boolean; + game: GameModel | undefined; + pins: { [playerId: string]: string[] }; +}) { + const [groups, setGroups] = useState<{ [key: string]: Item[] }>({}); + + useEffect(() => { + const groups: { [key: string]: Item[] } = {}; + achievements.forEach((achievement) => { + // If the achievement is hidden it should be shown in a dedicated group + const group = + achievement.hidden && !achievement.completed + ? HIDDEN_GROUP + : achievement.group; + groups[group] = groups[group] || []; + groups[group].push(achievement); + groups[group] + .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => a.index - b.index); + }); + setGroups(groups); + }, [achievements]); + + const { completed, total } = useMemo( + () => ({ + completed: achievements.filter((item) => item.completed).length, + total: achievements.length, + }), + [achievements], + ); + + return ( + +
+
+
+ + + </div> + <Total completed={completed} total={total} /> + </div> + <div className="flex flex-col gap-3"> + {Object.entries(groups) + .filter(([group]) => group !== HIDDEN_GROUP) + .map(([group, items]) => ( + <Group + key={group} + group={group} + items={items} + softview={softview} + enabled={enabled} + game={game} + pins={pins} + /> + ))} + <Group + key={HIDDEN_GROUP} + group={HIDDEN_GROUP} + items={(groups[HIDDEN_GROUP] || []).sort( + (a, b) => a.earning - b.earning, + )} + softview={softview} + enabled={enabled} + game={game} + pins={pins} + /> + </div> + </div> + </ScrollArea> + ); +} + +function Logo({ imageUrl, name }: { imageUrl: string; name: string }) { + const [imageError, setImageError] = useState(false); + return imageError ? ( + <JoystickIcon className="h-8 w-8" size="xs" variant="solid" /> + ) : ( + <img + src={imageUrl} + alt={name} + className="h-8 w-8 object-contain" + onError={() => setImageError(true)} + /> + ); +} + +function Title({ name }: { name: string }) { + return <p className="text-sm">{name}</p>; +} + +function Total({ completed, total }: { completed: number; total: number }) { + return ( + <div className="h-8 py-2 flex items-center justify-between gap-4 rounded-md overflow-hidden"> + <div className="h-4 grow flex flex-col justify-center items-start bg-quaternary rounded-xl p-1"> + <div + style={{ width: `${Math.floor((100 * completed) / total)}%` }} + className={cn("grow bg-primary rounded-xl")} + /> + </div> + <p className="text-xs text-muted-foreground"> + {`${completed} of ${total}`} + </p> + </div> + ); +} + +function Group({ + group, + items, + softview, + enabled, + game, + pins, +}: { + group: string; + items: Item[]; + softview: boolean; + enabled: boolean; + game: GameModel | undefined; + pins: { [playerId: string]: string[] }; +}) { + const [page, setPage] = useState(0); + const [pages, setPages] = useState<number[]>([]); + + const visibles = useMemo(() => { + return items.filter((a) => a.index === page || (a.hidden && !a.completed)); + }, [items, page]); + + useEffect(() => { + // Set the page to the first uncompleted achievement or 0 if there are none + const filtereds = items.filter((a) => !a.hidden || a.completed); + // Get the unique list of indexes for the achievements in this group + const pages = + filtereds.length > 0 ? [...new Set(filtereds.map((a) => a.index))] : [0]; + setPages(pages); + const page = filtereds.find((a) => !a.completed); + setPage(page ? page.index : pages[pages.length - 1]); + }, [items]); + + const handleNext = useCallback(() => { + const index = pages.indexOf(page); + const next = pages[index + 1]; + if (!next) return; + setPage(next); + }, [page, pages]); + + const handlePrevious = useCallback(() => { + const index = pages.indexOf(page); + if (index === 0) return; + setPage(pages[index - 1]); + }, [page, pages]); + + if (visibles.length === 0) return null; + + return ( + <div className="flex flex-col gap-y-px rounded-md overflow-hidden"> + <Header + group={group} + page={page} + pages={pages} + items={items} + setPage={setPage} + handleNext={handleNext} + handlePrevious={handlePrevious} + /> + {visibles.map((achievement) => ( + <Trophy + key={achievement.id} + icon={ + achievement.hidden && !achievement.completed + ? "fa-trophy" + : achievement.icon + } + title={ + achievement.hidden && !achievement.completed + ? "Hidden Achievement" + : achievement.title + } + description={ + achievement.hidden && !achievement.completed + ? "" + : achievement.description + } + percentage={achievement.percentage} + earning={achievement.earning} + timestamp={achievement.timestamp} + hidden={achievement.hidden} + completed={achievement.completed} + id={achievement.id} + softview={softview} + enabled={enabled} + tasks={achievement.tasks} + game={game} + pins={pins} + /> + ))} + </div> + ); +} + +function Header({ + group, + page, + pages, + items, + setPage, + handleNext, + handlePrevious, +}: { + group: string; + page: number; + pages: number[]; + items: Item[]; + setPage: (page: number) => void; + handleNext: () => void; + handlePrevious: () => void; +}) { + return ( + <div className="flex gap-x-px items-center h-8"> + <div className="grow h-full p-3 bg-secondary flex items-center"> + <p className="uppercase text-xs text-muted-foreground font-bold tracking-wider"> + {group} + </p> + </div> + {pages.length > 1 && ( + <> + {/* <Icon + className={cn( + "text-quaternary h-4 w-4", + disabled && "opacity-50", + )} + variant="solid" + /> */} + <Pagination + icon={<WedgeIcon variant="left" size="xs" />} + onClick={handlePrevious} + disabled={page === pages[0]} + /> + <Pagination + icon={<WedgeIcon variant="right" size="xs" />} + onClick={handleNext} + disabled={page === pages[pages.length - 1]} + /> + <div className="flex items-center justify-center h-full p-3 bg-secondary gap-2"> + <div className="flex items-center justify-center rounded-xl bg-quaternary p-[3px]"> + <div className="flex items-center justify-center rounded-xl overflow-hidden gap-x-px"> + {pages.map((current) => ( + <Page + key={current} + index={current} + completed={items + .filter((a) => a.index === current) + .every((a) => a.completed)} + highlighted={current === page} + setPage={setPage} + /> + ))} + </div> + </div> + </div> + </> + )} + </div> + ); +} + +function Pagination({ + icon, + onClick, + disabled, +}: { + icon: React.ReactNode; + onClick: () => void; + disabled: boolean; +}) { + return ( + <div + className={cn( + "flex items-center justify-center h-8 w-8 bg-secondary", + !disabled && "cursor-pointer hover:opacity-70", + )} + onClick={onClick} + > + <div className="text-muted-foreground">{icon}</div> + </div> + ); +} + +function Page({ + index, + completed, + highlighted, + setPage, +}: { + index: number; + completed: boolean; + highlighted: boolean; + setPage: (page: number) => void; +}) { + return ( + <div + className={cn( + "bg-primary h-[10px] w-[10px] opacity-50 hover:cursor-pointer hover:opacity-100", + completed ? "bg-primary" : "bg-muted", + highlighted && "opacity-100", + )} + onClick={() => setPage(index)} + /> + ); +} diff --git a/client/src/components/achievements/trophy.tsx b/client/src/components/achievements/trophy.tsx new file mode 100644 index 0000000..22f7f72 --- /dev/null +++ b/client/src/components/achievements/trophy.tsx @@ -0,0 +1,442 @@ +import { + cn, + TrackIcon, + CalendarIcon, + SparklesIcon, + CheckboxCheckedDuoIcon, + CheckboxUncheckedIcon, + Separator, + SpinnerIcon, + XIcon, +} from "@cartridge/ui-next"; +import { toast } from "sonner"; +import { useMemo, useState } from "react"; +import { useCallback } from "react"; +import { useArcade } from "@/hooks/arcade"; +import { addAddressPadding } from "starknet"; +import { GameModel } from "@bal7hazar/arcade-sdk"; +import { useAccount } from "@starknet-react/core"; + +export interface Task { + id: string; + count: number; + total: number; + description: string; +} + +export function Trophy({ + icon, + title, + description, + percentage, + earning, + timestamp, + hidden, + completed, + id, + softview, + enabled, + tasks, + game, + pins, +}: { + icon: string; + title: string; + description: string; + percentage: string; + earning: number; + timestamp: number; + hidden: boolean; + completed: boolean; + id: string; + softview: boolean; + enabled: boolean; + tasks: Task[]; + game: GameModel | undefined; + pins: { [playerId: string]: string[] }; +}) { + return ( + <div className="flex items-stretch gap-x-px"> + <div className="grow flex flex-col items-stretch gap-y-3 bg-secondary p-3"> + <div className="flex items-center gap-3"> + <Icon icon={icon} completed={completed} /> + <div className="grow flex flex-col"> + <div className="flex justify-between items-center"> + <Title title={title} completed={completed} /> + <div className="flex items-center gap-2"> + {completed && <Timestamp timestamp={timestamp} />} + {completed && ( + <Separator + className="text-muted-foreground h-2" + orientation="vertical" + /> + )} + <Earning + amount={earning.toLocaleString()} + completed={completed} + /> + </div> + </div> + <Details percentage={percentage} /> + </div> + </div> + <Description description={description} /> + {(!hidden || completed) && ( + <div className="flex flex-col gap-y-2"> + {tasks.map((task) => ( + <Task key={task.id} task={task} completed={completed} /> + ))} + </div> + )} + </div> + <div className="flex flex-col gap-y-px"> + <Track enabled={enabled && completed} id={id} pins={pins} /> + {completed && !softview && !!game && ( + <Share + title={title} + game={game} + earning={earning} + timestamp={timestamp} + percentage={percentage} + /> + )} + </div> + </div> + ); +} + +function Task({ task, completed }: { task: Task; completed: boolean }) { + const TaskIcon = useMemo(() => { + if (task.count >= task.total) { + return CheckboxCheckedDuoIcon; + } + return CheckboxUncheckedIcon; + }, [task.count, task.total]); + + return ( + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-x-2"> + <TaskIcon className="text-muted-foreground" size="xs" /> + <p + className={cn( + "text-xs text-muted-foreground", + task.count >= task.total && "line-through opacity-50", + )} + > + {task.description} + </p> + </div> + <Progress count={task.count} total={task.total} completed={completed} /> + </div> + ); +} + +function Icon({ icon, completed }: { icon: string; completed: boolean }) { + return ( + <div + className={cn( + "w-8 h-8 flex items-center justify-center", + completed ? "text-primary" : "text-muted-foreground", + )} + > + <div className={cn("w-6 h-6", icon, "fa-solid")} /> + </div> + ); +} + +function Title({ title, completed }: { title: string; completed: boolean }) { + const overflow = useMemo(() => title.length > 27, [title]); + const content = useMemo(() => { + if (!overflow) return title; + return title.slice(0, 24) + "..."; + }, [title, overflow]); + return ( + <p + className={cn( + "text-sm text-accent-foreground capitalize font-medium", + completed && "text-foreground", + )} + > + {content} + </p> + ); +} + +function Description({ description }: { description: string }) { + const [full, setFull] = useState(false); + const [bright, setBright] = useState(false); + const visible = useMemo(() => description.length > 100, [description]); + const content = useMemo(() => { + if (!visible || full) { + return description.slice(0, 1).toUpperCase() + description.slice(1); + } + return ( + description.slice(0, 1).toUpperCase() + description.slice(1, 100) + "..." + ); + }, [description, visible, full]); + + if (description.length === 0) return null; + return ( + <p className="block text-xs text-accent-foreground"> + {content} + {visible && ( + <span + className={cn( + "text-muted-foreground cursor-pointer", + full && "block", + bright ? "brightness-150" : "brightness-100", + )} + onClick={() => setFull(!full)} + onMouseEnter={() => setBright(true)} + onMouseLeave={() => setBright(false)} + > + {full ? " read less" : " read more"} + </span> + )} + </p> + ); +} + +function Details({ percentage }: { percentage: string }) { + return ( + <p className="text-[0.65rem] text-muted-foreground">{`${percentage}% of players earned`}</p> + ); +} + +function Earning({ + amount, + completed, +}: { + amount: string; + completed: boolean; +}) { + return ( + <div + className={cn( + "flex items-center gap-1 text-muted-foreground font-medium", + completed && "opacity-50", + )} + > + <SparklesIcon size="xs" variant={completed ? "solid" : "line"} /> + <p className={cn("text-sm", completed && "line-through")}>{amount}</p> + </div> + ); +} + +function Timestamp({ timestamp }: { timestamp: number }) { + const date = useMemo(() => { + const date = new Date(timestamp * 1000); + const today = new Date(); + if (date.getDate() === today.getDate()) { + return "Today"; + } else if (date.getDate() === today.getDate() - 1) { + return "Yesterday"; + } else { + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + } + }, [timestamp]); + + return ( + <div className="flex items-center gap-1 text-muted-foreground"> + <CalendarIcon size="xs" variant="line" /> + <p className="text-xs">{date}</p> + </div> + ); +} + +function Progress({ + count, + total, + completed, +}: { + count: number; + total: number; + completed: boolean; +}) { + return ( + <div className="flex gap-4"> + <div className="grow flex flex-col justify-center items-start bg-quaternary rounded-xl p-1"> + <div + style={{ + width: `${Math.floor((100 * Math.min(count, total)) / total)}%`, + }} + className={cn( + "grow bg-accent-foreground rounded-xl", + completed ? "bg-primary" : "text-muted-foreground", + )} + /> + </div> + {count >= total ? ( + <div className="flex items-center gap-x-2"> + <div className="fa-solid fa-check text-xs text-muted-foreground" /> + <p className="text-xs text-muted-foreground font-medium"> + {total > 1 ? `${count.toLocaleString()}` : "Completed"} + </p> + </div> + ) : ( + <p className="text-xs text-muted-foreground font-medium"> + {`${count.toLocaleString()} of ${total.toLocaleString()}`} + </p> + )} + </div> + ); +} + +function Track({ + id, + enabled, + pins, +}: { + id: string; + enabled: boolean; + pins: { [playerId: string]: string[] }; +}) { + const { account, address } = useAccount(); + const { chainId, provider } = useArcade(); + + const [hovered, setHovered] = useState(false); + const [loading, setLoading] = useState(false); + + const pinned = useMemo(() => { + return pins[addAddressPadding(address || "0x0")]?.includes(id); + }, [pins, address, id]); + + const handlePin = useCallback(() => { + if (!enabled || pinned || !account) return; + const pin = async () => { + setLoading(true); + try { + const calls = provider.social.pin({ achievementId: id }); + const { transaction_hash } = await account.execute(calls); + const receipt = await account.waitForTransaction(transaction_hash); + if (receipt.isSuccess()) { + toast.success(`Trophy pinned successfully`); + } + } catch (error) { + console.error(error); + toast.error("Failed to pin trophy"); + } finally { + setLoading(false); + } + }; + pin(); + }, [enabled, pinned, id, chainId, provider, account]); + + const handleUnpin = useCallback(() => { + if (!pinned || !account) return; + const unpin = async () => { + setLoading(true); + try { + const calls = provider.social.unpin({ achievementId: id }); + const { transaction_hash } = await account.execute(calls); + const receipt = await account.waitForTransaction(transaction_hash); + if (receipt.isSuccess()) { + toast.success(`Trophy unpinned successfully`); + } + } catch (error) { + console.error(error); + toast.error("Failed to unpin trophy"); + } finally { + setLoading(false); + } + }; + unpin(); + }, [pinned, id, chainId, provider, account]); + + return ( + <div + className={cn( + "grow bg-secondary p-2 flex items-center transition-all duration-200", + hovered && + (enabled || pinned) && + "opacity-90 bg-secondary/50 cursor-pointer", + pinned && "bg-quaternary", + )} + onClick={pinned ? handleUnpin : handlePin} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {loading ? ( + <SpinnerIcon className="text-muted-foreground animate-spin" size="sm" /> + ) : ( + <TrackIcon + className={cn(!enabled && !pinned && "opacity-25")} + size="sm" + variant={pinned ? "solid" : "line"} + /> + )} + </div> + ); +} + +function Share({ + game, + title, + earning, + timestamp, + percentage, +}: { + game: GameModel; + title: string; + earning: number; + timestamp: number; + percentage: string; +}) { + const url: string | null = useMemo(() => { + if (!game.socials.website) return null; + return game.socials.website; + }, [game]); + + const xhandle = useMemo(() => { + if (!game.socials.twitter) return null; + // Take the last part of the url + return game.socials.twitter.split("/").pop(); + }, [game]); + + const date = useMemo(() => { + const date = new Date(timestamp * 1000); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }, [timestamp]); + + const handleShare = useCallback(() => { + if (!url || !xhandle) return; + const content = `🚨 THIS ISN’T JUST AN ACHIEVEMENT. IT’S HISTORY. + +πŸ† ${title} Unlocked in @${xhandle} + +✨ +${earning} Points | πŸ“… ${date} + +Only ${percentage}% of players have earned this rare badge. + +Do you have what it takes to carve your name into history? + +πŸ’₯ Prove it. Play now πŸ‘‡`; + + const twitterUrl = `https://x.com/intent/tweet?text=${encodeURIComponent( + content, + )}&url=${encodeURIComponent(url)}`; + + window.open(twitterUrl, "_blank", "noopener,noreferrer"); + }, [url, xhandle, title, earning, date, percentage]); + + if (!url || !xhandle) return null; + + return ( + <div + className={cn( + "grow bg-secondary p-2 flex items-center transition-all duration-200 hover:opacity-90 hover:cursor-pointer", + )} + onClick={handleShare} + > + <XIcon size="sm" /> + </div> + ); +} diff --git a/client/src/components/app.tsx b/client/src/components/app.tsx index c161fa0..75b20c2 100644 --- a/client/src/components/app.tsx +++ b/client/src/components/app.tsx @@ -1,16 +1,40 @@ import { Outlet, Route, Routes } from "react-router-dom"; import { InventoryScene } from "./scenes/inventory"; +import { AchievementScene } from "./scenes/achievement"; +import { Games } from "@/components/games"; +import { User } from "@/components/user"; +import { Navigation } from "@/components/navigation"; +import { SceneLayout } from "@/components/scenes/layout"; export function App() { + return ( + <SceneLayout> + <div className="w-full bg-background h-[calc(100vh-3.5rem)]"> + <div className="w-[1048px] flex flex-col items-stretch m-auto gap-y-8"> + <div className="flex justify-between items-center pt-8"> + <User /> + <Navigation /> + </div> + <div className="flex justify-center gap-8"> + <Games /> + <Router /> + </div> + </div> + </div> + </SceneLayout> + ); +} + +const Router = () => { return ( <Routes> <Route element={<Outlet />}> <Route path="/" element={<InventoryScene />} /> <Route path="/inventory" element={<InventoryScene />} /> - <Route path="/achievements" element={<InventoryScene />} /> + <Route path="/achievements" element={<AchievementScene />} /> <Route path="/activity" element={<InventoryScene />} /> </Route> <Route path="*" element={<div>Page not found</div>} /> </Routes> ); -} +}; diff --git a/client/src/components/games.tsx b/client/src/components/games/index.tsx similarity index 78% rename from client/src/components/games.tsx rename to client/src/components/games/index.tsx index b23c98d..c0725a7 100644 --- a/client/src/components/games.tsx +++ b/client/src/components/games/index.tsx @@ -1,20 +1,36 @@ import { JoystickIcon, ScrollArea, SparklesIcon, cn } from "@cartridge/ui-next"; -import { useCallback, useState } from "react"; -import { useTheme } from "@/hooks/context"; +import { useCallback, useMemo, useState } from "react"; +import { useData, useTheme } from "@/hooks/context"; import { ControllerTheme, controllerConfigs as configs, } from "@cartridge/controller"; import { useArcade } from "@/hooks/arcade"; +import { useProject } from "@/hooks/project"; +import { useAccount } from "@/hooks/account"; export const Games = () => { const [selected, setSelected] = useState(0); const { games } = useArcade(); + const { address } = useAccount(); + const { + trophies: { players }, + } = useData(); + + const points = useMemo(() => { + return ( + players.find( + (player) => BigInt(player.address || 0) === BigInt(address || 0), + )?.earnings || 0 + ); + }, [address, players]); return ( <div className="flex flex-col gap-y-px w-[324px] rounded-lg pb-8 grow overflow-y-auto h-[661px]"> <Game index={0} + project="arcade" + namespace="" preset="default" name="All" icon="" @@ -27,10 +43,12 @@ export const Games = () => { <Game key={`${game.worldAddress}-${game.namespace}`} index={index + 1} + project={game.project} + namespace={game.namespace} preset={game.preset ?? "default"} name={game.metadata.name} icon={game.metadata.image} - points={game.karma} + points={points} active={selected === index + 1} setSelected={setSelected} /> @@ -42,6 +60,8 @@ export const Games = () => { export const Game = ({ index, + project, + namespace, preset, name, icon, @@ -50,6 +70,8 @@ export const Game = ({ setSelected, }: { index: number; + project: string; + namespace: string; preset: string; name: string; icon: string; @@ -58,9 +80,12 @@ export const Game = ({ setSelected: (index: number) => void; }) => { const { theme, setTheme, resetTheme } = useTheme(); + const { setProject, setNamespace } = useProject(); const handleClick = useCallback(() => { setSelected(index); + setProject(project); + setNamespace(namespace); const config = configs[preset.toLowerCase()].theme; if (!config || !config.colors) { return resetTheme(); @@ -73,7 +98,7 @@ export const Game = ({ }, }; setTheme(newTheme); - }, [index, theme, setSelected, setTheme]); + }, [index, project, namespace, theme, setSelected, setTheme, setProject]); return ( <div diff --git a/client/src/components/scenes/achievement.tsx b/client/src/components/scenes/achievement.tsx new file mode 100644 index 0000000..ae52c49 --- /dev/null +++ b/client/src/components/scenes/achievement.tsx @@ -0,0 +1,5 @@ +import { Achievements } from "@/components/achievements"; + +export const AchievementScene = () => { + return <Achievements />; +}; diff --git a/client/src/components/scenes/inventory.tsx b/client/src/components/scenes/inventory.tsx index 4076cd7..279d216 100644 --- a/client/src/components/scenes/inventory.tsx +++ b/client/src/components/scenes/inventory.tsx @@ -1,24 +1,5 @@ -import { Games } from "../games"; -import { User } from "../user"; -import { Navigation } from "../navigation"; -import { Inventory } from "../inventory"; -import { SceneLayout } from "./layout"; +import { Inventory } from "@/components/inventory"; export const InventoryScene = () => { - return ( - <SceneLayout> - <div className="w-full bg-background h-[calc(100vh-3.5rem)]"> - <div className="w-[1048px] flex flex-col items-stretch m-auto gap-y-8"> - <div className="flex justify-between items-center pt-8"> - <User /> - <Navigation /> - </div> - <div className="flex justify-center gap-8"> - <Games /> - <Inventory /> - </div> - </div> - </div> - </SceneLayout> - ); + return <Inventory />; }; diff --git a/client/src/context/project.tsx b/client/src/context/project.tsx index e05526b..6b05095 100644 --- a/client/src/context/project.tsx +++ b/client/src/context/project.tsx @@ -2,24 +2,31 @@ import { createContext, useState, ReactNode, useMemo } from "react"; type ProjectContextType = { isReady: boolean; + project: string; + namespace: string; indexerUrl: string; setProject: (project: string) => void; + setNamespace: (namespace: string) => void; }; const initialState: ProjectContextType = { isReady: false, + project: "arcade", + namespace: "", indexerUrl: "", setProject: () => {}, + setNamespace: () => {}, }; export const ProjectContext = createContext<ProjectContextType>(initialState); export function ProjectProvider({ children }: { children: ReactNode }) { - const [project, setProject] = useState<string>("arcade"); + const [project, setProject] = useState<string>(initialState.project); + const [namespace, setNamespace] = useState<string>(initialState.namespace); const indexerUrl = useMemo(() => { if (!project) return ""; - return `${import.meta.env.VITE_CARTRIDGE_API_URL.replace("cartridge", project)}/torii`; + return `https://api.cartridge.gg/x/${project}/torii`; }, [project]); const isReady = useMemo(() => { @@ -27,7 +34,16 @@ export function ProjectProvider({ children }: { children: ReactNode }) { }, [indexerUrl]); return ( - <ProjectContext.Provider value={{ isReady, indexerUrl, setProject }}> + <ProjectContext.Provider + value={{ + isReady, + project, + namespace, + indexerUrl, + setProject, + setNamespace, + }} + > {children} </ProjectContext.Provider> ); diff --git a/client/src/hooks/achievements.ts b/client/src/hooks/achievements.ts index ca8f206..2105354 100644 --- a/client/src/hooks/achievements.ts +++ b/client/src/hooks/achievements.ts @@ -1,10 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import { TROPHY, PROGRESS } from "@/constants"; import { Trophy, Progress, Task } from "@/models"; -import { useConnection } from "./context"; -import { useAccount } from "./account"; +import { useAccount } from "@starknet-react/core"; import { useProgressions } from "./progressions"; import { useTrophies } from "./trophies"; +import { useProject } from "./project"; export interface Item { id: string; @@ -45,73 +45,34 @@ export interface Player { } export function useAchievements(accountAddress?: string) { - const { project, namespace } = useConnection(); + const { project, namespace } = useProject(); const { address } = useAccount(); const [isLoading, setIsLoading] = useState(true); const [achievements, setAchievements] = useState<Item[]>([]); const [players, setPlayers] = useState<Player[]>([]); - const [cachedTrophies, setCachedTrophies] = useState<Trophy[]>([]); - const [cachedProgressions, setCachedProgressions] = useState<Progress[]>([]); const currentAddress = useMemo(() => { - return accountAddress || address; + return `0x${BigInt(accountAddress || address || "0x0").toString(16)}`; }, [accountAddress, address]); - const { trophies: rawTrophies, isFetching: isFetchingTrophies } = useTrophies( - { - namespace: namespace ?? "", - name: TROPHY, - project: project ?? "", - parser: Trophy.parse, - }, - ); - - const { progressions: rawProgressions, isFetching: isFetchingProgressions } = - useProgressions({ - namespace: namespace ?? "", - name: PROGRESS, - project: project ?? "", - parser: Progress.parse, - }); + const { trophies } = useTrophies({ + namespace: namespace ?? "", + name: TROPHY, + project: project ?? "", + parser: Trophy.parse, + }); - useEffect(() => { - if (!isFetchingTrophies && cachedTrophies.length !== rawTrophies.length) { - setCachedTrophies(rawTrophies); - } - if ( - !isFetchingProgressions && - Math.max(...rawProgressions.map((p) => p.timestamp)) > - Math.max(...cachedProgressions.map((p) => p.timestamp)) - ) { - setCachedProgressions(rawProgressions); - } - }, [ - rawTrophies, - rawProgressions, - cachedTrophies, - cachedProgressions, - isFetchingTrophies, - isFetchingProgressions, - ]); + const { progressions } = useProgressions({ + namespace: namespace ?? "", + name: PROGRESS, + project: project ?? "", + parser: Progress.parse, + }); // Compute achievements and players useEffect(() => { - if (!cachedTrophies.length || !currentAddress) return; - - // Merge trophies - const trophies: { [id: string]: Trophy } = {}; - cachedTrophies.forEach((trophy) => { - if (Object.keys(trophies).includes(trophy.id)) { - trophy.tasks.forEach((task) => { - if (!trophies[trophy.id].tasks.find((t) => t.id === task.id)) { - trophies[trophy.id].tasks.push(task); - } - }); - } else { - trophies[trophy.id] = trophy; - } - }); + if (!Object.values(trophies).length || !currentAddress) return; // Compute players and achievement stats const data: { @@ -125,13 +86,15 @@ export function useAchievements(accountAddress?: string) { }; }; } = {}; - cachedProgressions.forEach((progress: Progress) => { + Object.values(progressions).forEach((progress: Progress) => { const { achievementId, playerId, taskId, taskTotal, total, timestamp } = progress; // Compute player const detaultTasks: { [taskId: string]: boolean } = {}; - trophies[achievementId].tasks.forEach((task: Task) => { + const trophy = trophies[achievementId]; + if (!trophy) return; + trophy.tasks.forEach((task: Task) => { detaultTasks[task.id] = false; }); data[playerId] = data[playerId] || {}; @@ -228,13 +191,7 @@ export function useAchievements(accountAddress?: string) { ); // Update loading state setIsLoading(false); - }, [ - currentAddress, - isFetchingTrophies, - isFetchingProgressions, - cachedTrophies, - cachedProgressions, - ]); + }, [currentAddress, trophies, progressions]); return { achievements, players, isLoading }; } diff --git a/client/src/hooks/progressions.ts b/client/src/hooks/progressions.ts index ac41638..b70b475 100644 --- a/client/src/hooks/progressions.ts +++ b/client/src/hooks/progressions.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Project, useProgressionsQuery } from "@cartridge/utils/api/cartridge"; import { Progress, RawProgress, getSelectorFromTag } from "@/models"; @@ -17,20 +17,26 @@ export function useProgressions({ project: string; parser: (node: RawProgress) => Progress; }) { + const [rawProgressions, setRawProgressions] = useState<{ + [key: string]: Progress; + }>({}); const [progressions, setProgressions] = useState<{ [key: string]: Progress }>( {}, ); // Fetch achievement creations from raw events - const projects: Project[] = [ - { model: getSelectorFromTag(namespace, name), namespace, project }, - ]; + const projects: Project[] = useMemo( + () => [{ model: getSelectorFromTag(namespace, name), namespace, project }], + [namespace, name, project], + ); + const { refetch: fetchProgressions, isFetching } = useProgressionsQuery( { projects, }, { enabled: !!namespace && !!project, + queryKey: ["progressions", namespace, name, project], refetchInterval: 600_000, // Refetch every 10 minutes onSuccess: ({ playerAchievements }: { playerAchievements: Response }) => { const progressions = playerAchievements.items[0].achievements @@ -39,7 +45,7 @@ export function useProgressions({ acc[achievement.key] = achievement; return acc; }, {}); - setProgressions((previous) => ({ ...previous, ...progressions })); + setRawProgressions(progressions); }, }, ); @@ -54,5 +60,10 @@ export function useProgressions({ } }, [namespace, project, fetchProgressions]); - return { progressions: Object.values(progressions), isFetching }; + useEffect(() => { + if (isFetching) return; + setProgressions(rawProgressions); + }, [rawProgressions, isFetching]); + + return { progressions }; } diff --git a/client/src/hooks/project.ts b/client/src/hooks/project.ts index d300632..c06f75d 100644 --- a/client/src/hooks/project.ts +++ b/client/src/hooks/project.ts @@ -18,7 +18,8 @@ export const useProject = () => { ); } - const { isReady, indexerUrl } = context; + const { isReady, project, namespace, indexerUrl, setProject, setNamespace } = + context; - return { isReady, indexerUrl }; + return { isReady, project, namespace, indexerUrl, setProject, setNamespace }; }; diff --git a/client/src/hooks/trophies.ts b/client/src/hooks/trophies.ts index 93e1446..b07b8e8 100644 --- a/client/src/hooks/trophies.ts +++ b/client/src/hooks/trophies.ts @@ -17,6 +17,7 @@ export function useTrophies({ project: string; parser: (node: RawTrophy) => Trophy; }) { + const [rawTrophies, setRawTrophies] = useState<{ [key: string]: Trophy }>({}); const [trophies, setTrophies] = useState<{ [key: string]: Trophy }>({}); // Fetch achievement creations from raw events @@ -29,6 +30,7 @@ export function useTrophies({ }, { enabled: !!namespace && !!project, + queryKey: ["achievements", namespace, name, project], refetchInterval: 600_000, // Refetch every 10 minutes onSuccess: ({ achievements }: { achievements: Response }) => { const trophies = achievements.items[0].achievements @@ -37,7 +39,7 @@ export function useTrophies({ acc[achievement.key] = achievement; return acc; }, {}); - setTrophies((previous) => ({ ...trophies, ...previous })); + setRawTrophies({ ...trophies }); }, }, ); @@ -52,5 +54,23 @@ export function useTrophies({ } }, [namespace, project, fetchAchievements]); - return { trophies: Object.values(trophies), isFetching }; + useEffect(() => { + if (isFetching) return; + // Merge trophies + const trophies: { [id: string]: Trophy } = {}; + Object.values(rawTrophies).forEach((trophy) => { + if (Object.keys(trophies).includes(trophy.id)) { + trophy.tasks.forEach((task) => { + if (!trophies[trophy.id].tasks.find((t) => t.id === task.id)) { + trophies[trophy.id].tasks.push(task); + } + }); + } else { + trophies[trophy.id] = trophy; + } + }); + setTrophies(trophies); + }, [rawTrophies, isFetching, setTrophies]); + + return { trophies }; } diff --git a/contracts/dojo_mainnet.toml b/contracts/dojo_mainnet.toml index 939a59f..5cc1a75 100644 --- a/contracts/dojo_mainnet.toml +++ b/contracts/dojo_mainnet.toml @@ -24,6 +24,8 @@ default = "ARCADE" [env] rpc_url = "https://api.cartridge.gg/x/starknet/mainnet" account_address = "0x041aad5a7493b75f240f418cb5f052d1a68981af21e813ed0a35e96d3e83123b" +world_address = "0x389e47f34690ea699218305cc28cc910028533d61d27773e1db20e0b78e7b65" +world_block = 1086641 ipfs_config.url = "https://ipfs.infura.io:5001" ipfs_config.username = "2EBrzr7ZASQZKH32sl2xWauXPSA" ipfs_config.password = "12290b883db9138a8ae3363b6739d220" diff --git a/contracts/manifest_mainnet.json b/contracts/manifest_mainnet.json index 7a075b2..f4ad783 100644 --- a/contracts/manifest_mainnet.json +++ b/contracts/manifest_mainnet.json @@ -1253,7 +1253,7 @@ "contracts": [ { "address": "0xc46f7e578f31c3fa6bb669164f04d696427818ba69f177ddc152f31ed5f119", - "class_hash": "0x5e1763655cb615aa7e6296c0ee1350198266ccf335c191d7cc0cb3b2c203341", + "class_hash": "0x1a07f7bce8ac8fd51cc09b5a9d4072023a3f001eb1f483c0879e9ee6bf5c304", "abi": [ { "type": "impl", @@ -1402,6 +1402,10 @@ "name": "namespace", "type": "core::felt252" }, + { + "name": "project", + "type": "core::felt252" + }, { "name": "preset", "type": "core::felt252" diff --git a/contracts/scripts/darkshuffle.sh b/contracts/scripts/darkshuffle.sh new file mode 100755 index 0000000..4b6a5d0 --- /dev/null +++ b/contracts/scripts/darkshuffle.sh @@ -0,0 +1,21 @@ +# Register game + +starkli invoke \ + --rpc https://api.cartridge.gg/x/starknet/mainnet \ + --account ./account_mainnet.json \ + --keystore ./keystore_mainnet.json \ + 0xc46f7e578f31c3fa6bb669164f04d696427818ba69f177ddc152f31ed5f119 register_game \ + 0x44c4ffdf401b1fe8c4b75eb8c8076d28dd91940ea31de93814dc82e1410b86e \ + str:"darkshuffle_s0" \ + str:"darkshuffle-mainnet-3" \ + str:"dark-shuffle" \ + str:"#F59100" \ + 0 str:"Dark Shuffle" 0xc \ + 2 str:"A Provable Roguelike Deck-build" str:"ing Game on Starknet, powered b" str:"y LORDS." 0x8 \ + 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/dark" str:"-shuffle/icon.svg?raw=true" 0x1a \ + 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/dark" str:"-shuffle/cover.png?raw=true" 0x1b \ + 0 str:"https://discord.gg/CEXUEJF3" 0x1b \ + 0 0 0 \ + 0 str:"https://x.com/await_0x" 0x16 \ + 0 0 0 \ + 0 str:"https://darkshuffle.dev/" 0x18 \ No newline at end of file diff --git a/contracts/scripts/remove.sh b/contracts/scripts/remove.sh new file mode 100755 index 0000000..e1ae191 --- /dev/null +++ b/contracts/scripts/remove.sh @@ -0,0 +1,9 @@ +# Register game + +starkli invoke \ + --rpc https://api.cartridge.gg/x/starknet/mainnet \ + --account ./account_mainnet.json \ + --keystore ./keystore_mainnet.json \ + 0xc46f7e578f31c3fa6bb669164f04d696427818ba69f177ddc152f31ed5f119 remove_game \ + 0x44c4ffdf401b1fe8c4b75eb8c8076d28dd91940ea31de93814dc82e1410b86e \ + str:"darkshuffle_s0" diff --git a/contracts/scripts/seeding_mainnet.sh b/contracts/scripts/seeding_mainnet.sh index 7295509..faca8ee 100755 --- a/contracts/scripts/seeding_mainnet.sh +++ b/contracts/scripts/seeding_mainnet.sh @@ -22,12 +22,12 @@ starkli invoke \ / 0xc46f7e578f31c3fa6bb669164f04d696427818ba69f177ddc152f31ed5f119 register_game \ 0x6a9e4c6f0799160ea8ddc43ff982a5f83d7f633e9732ce42701de1288ff705f \ str:"s0_eternum" \ - str:"eternum-prod" \ + str:"eternum" \ str:"eternum" \ str:"#FF00FF" \ 0 str:"Eternum" 0x7 \ 0 str:"Rule the Hex." 0xd \ - 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/eter" str:"num/icon.png?raw=true" 0x15 \ + 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/eter" str:"num/icon.gif?raw=true" 0x15 \ 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/eter" str:"num/cover.png?raw=true" 0x16 \ 0 str:"https://discord.gg/CEXUEJF3" 0x1b \ 0 0 0 \ @@ -41,11 +41,26 @@ starkli invoke \ str:"dark-shuffle" \ str:"#F59100" \ 0 str:"Dark Shuffle" 0x7 \ - 2 str:"A Provable Roguelike Deck-build" str:"ing Game on Starknet, powered b" str:"y $LORDS." 0x9 \ + 2 str:"A Provable Roguelike Deck-build" str:"ing Game on Starknet, powered b" str:"y LORDS." 0x8 \ 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/dark" str:"-shuffle/icon.png?raw=true" 0x1a \ 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/dark" str:"-shuffle/cover.png?raw=true" 0x1b \ 0 str:"https://discord.gg/CEXUEJF3" 0x1b \ 0 0 0 \ 0 str:"https://x.com/await_0x" 0x16 \ 0 0 0 \ - 0 str:"https://darkshuffle.dev/" 0x18 + 0 str:"https://darkshuffle.dev/" 0x18 \ + / 0xc46f7e578f31c3fa6bb669164f04d696427818ba69f177ddc152f31ed5f119 register_game \ + 0x030d5d5c610dd736faea146b20b850af64e34ca6e5c5a66462f76f32f48dd997 \ + str:"zkube" \ + str:"zkube-mainnet" \ + str:"zkube" \ + str:"#5bc3e6" \ + 0 str:"zKube" 0x5 \ + 0 str:"Reversed tetris fully onchain." 0x1e \ + 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/zkub" str:"e/icon.png?raw=true" 0x13 \ + 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/zkub" str:"e/cover.png?raw=true" 0x14 \ + 0 str:"https://discord.gg/CEXUEJF3" 0x1b \ + 0 0 0 \ + 0 str:"https://x.com/zKorp_" 0x14 \ + 0 0 0 \ + 0 str:"https://app.zkube.xyz/" 0x16 diff --git a/contracts/scripts/seeding_sepolia.sh b/contracts/scripts/seeding_sepolia.sh index db398dd..a4140e2 100755 --- a/contracts/scripts/seeding_sepolia.sh +++ b/contracts/scripts/seeding_sepolia.sh @@ -22,7 +22,7 @@ starkli invoke \ / 0xc46f7e578f31c3fa6bb669164f04d696427818ba69f177ddc152f31ed5f119 register_game \ 0x6a9e4c6f0799160ea8ddc43ff982a5f83d7f633e9732ce42701de1288ff705f \ str:"s0_eternum" \ - str:"eternum-prod" \ + str:"eternum" \ str:"eternum" \ str:"#FF00FF" \ 0 str:"Eternum" 0x7 \ diff --git a/contracts/scripts/update.sh b/contracts/scripts/update.sh new file mode 100755 index 0000000..ae93b8b --- /dev/null +++ b/contracts/scripts/update.sh @@ -0,0 +1,21 @@ +# Register game + +starkli invoke \ + --rpc https://api.cartridge.gg/x/starknet/mainnet \ + --account ./account_mainnet.json \ + --keystore ./keystore_mainnet.json \ + 0xc46f7e578f31c3fa6bb669164f04d696427818ba69f177ddc152f31ed5f119 update_game \ + 0x44c4ffdf401b1fe8c4b75eb8c8076d28dd91940ea31de93814dc82e1410b86e \ + str:"darkshuffle_s0" \ + str:"darkshuffle-mainnet-3" \ + str:"dark-shuffle" \ + str:"#F59100" \ + 0 str:"Dark Shuffle" 0xc \ + 2 str:"A Provable Roguelike Deck-build" str:"ing Game on Starknet, powered b" str:"y LORDS." 0x8 \ + 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/dark" str:"-shuffle/icon.svg?raw=true" 0x1a \ + 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/dark" str:"-shuffle/cover.png?raw=true" 0x1b \ + 0 str:"https://discord.gg/CEXUEJF3" 0x1b \ + 0 0 0 \ + 0 str:"https://x.com/await_0x" 0x16 \ + 0 0 0 \ + 0 str:"https://darkshuffle.dev/" 0x18 diff --git a/contracts/scripts/update_dopewars.sh b/contracts/scripts/update_dopewars.sh deleted file mode 100755 index 00d4398..0000000 --- a/contracts/scripts/update_dopewars.sh +++ /dev/null @@ -1,19 +0,0 @@ -# Register game - -starkli invoke \ - --rpc https://api.cartridge.gg/x/starknet/sepolia \ - --account ./account.json \ - --keystore ./keystore.json \ - 0x3af383ca009f2066f44ec9c6e760072b58142468bdf2b2b87053e4ee0012ed2 update_game \ - 0x4f3dccb47477c087ad9c76b8067b8aadded57f8df7f2d7543e6066bcb25332c \ - str:"dopewars" \ - str:"#FF00FF" \ - 0 str:"Dope Wars" 0x9 \ - 3 str:"Dope Wars is an onchain adaptat" str:"ion of the classic arbitrage ga" str:"me Drug Wars, built by Cartridg" str:"e in partnership with Dope DAO." 0x1f \ - 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/dope" str:"-wars/icon.png?raw=true" 0x17 \ - 2 str:"https://github.com/cartridge-gg" str:"/presets/blob/main/configs/dope" str:"-wars/cover.png?raw=true" 0x18 \ - 0 str:"https://discord.gg/dopewars" 0x1b \ - 0 0 0 \ - 0 str:"https://x.com/TheDopeWars" 0x19\ - 0 0 0 \ - 0 str:"https://dopewars.game/" 0x16 diff --git a/contracts/src/systems/registry.cairo b/contracts/src/systems/registry.cairo index 4881178..6ced2a6 100644 --- a/contracts/src/systems/registry.cairo +++ b/contracts/src/systems/registry.cairo @@ -23,6 +23,7 @@ trait IRegistry<TContractState> { ref self: TContractState, world_address: felt252, namespace: felt252, + project: felt252, preset: felt252, color: felt252, name: ByteArray, @@ -182,6 +183,7 @@ mod Registry { ref self: ContractState, world_address: felt252, namespace: felt252, + project: felt252, preset: felt252, color: felt252, name: ByteArray, @@ -203,6 +205,7 @@ mod Registry { caller, world_address, namespace, + project, preset, Option::Some(color), Option::Some(name), diff --git a/packages/registry/src/components/registerable.cairo b/packages/registry/src/components/registerable.cairo index d371fc3..f0ebd34 100644 --- a/packages/registry/src/components/registerable.cairo +++ b/packages/registry/src/components/registerable.cairo @@ -71,6 +71,7 @@ mod RegisterableComponent { caller_id: felt252, world_address: felt252, namespace: felt252, + project: felt252, preset: felt252, color: Option<felt252>, name: Option<ByteArray>, @@ -96,7 +97,7 @@ mod RegisterableComponent { // [Effect] Update game let metadata = MetadataTrait::new(color, name, description, image, banner); let socials = SocialsTrait::new(discord, telegram, twitter, youtube, website); - game.update(preset, metadata, socials); + game.update(project, preset, metadata, socials); // [Effect] Update game store.set_game(@game); diff --git a/packages/registry/src/models/game.cairo b/packages/registry/src/models/game.cairo index 66c0599..81e4740 100644 --- a/packages/registry/src/models/game.cairo +++ b/packages/registry/src/models/game.cairo @@ -77,8 +77,11 @@ impl GameImpl of GameTrait { } #[inline] - fn update(ref self: Game, preset: felt252, metadata: Metadata, socials: Socials) { + fn update( + ref self: Game, project: felt252, preset: felt252, metadata: Metadata, socials: Socials + ) { // [Effect] Update Game + self.project = project; self.preset = preset; self.metadata = metadata.jsonify(); self.socials = socials.jsonify(); @@ -262,14 +265,16 @@ mod tests { socials: core::Default::default(), owner: OWNER, ); - let preset = 'SETPRE'; + let project = 'TCEJORP'; + let preset = 'TESERP'; let metadata = MetadataTrait::new( Option::Some('123456'), Option::None, Option::None, Option::None, Option::None ); let socials = SocialsTrait::new( Option::Some("discord"), Option::None, Option::None, Option::None, Option::None ); - game.update(preset, metadata.clone(), socials.clone()); + game.update(project, preset, metadata.clone(), socials.clone()); + assert_eq!(game.project, project); assert_eq!(game.preset, preset); assert_eq!(game.metadata, metadata.clone().jsonify()); assert_eq!(game.socials, socials.clone().jsonify()); diff --git a/packages/registry/src/tests/mocks/register.cairo b/packages/registry/src/tests/mocks/register.cairo index d12e603..b773e35 100644 --- a/packages/registry/src/tests/mocks/register.cairo +++ b/packages/registry/src/tests/mocks/register.cairo @@ -21,6 +21,7 @@ trait IRegister<TContractState> { self: @TContractState, world_address: felt252, namespace: felt252, + project: felt252, preset: felt252, color: Option<felt252>, name: Option<ByteArray>, @@ -134,6 +135,7 @@ pub mod Register { self: @ContractState, world_address: felt252, namespace: felt252, + project: felt252, preset: felt252, color: Option<felt252>, name: Option<ByteArray>, @@ -155,6 +157,7 @@ pub mod Register { caller, world_address, namespace, + project, preset, color, name, diff --git a/packages/registry/src/tests/test_registerable.cairo b/packages/registry/src/tests/test_registerable.cairo index b641fca..e11bfd5 100644 --- a/packages/registry/src/tests/test_registerable.cairo +++ b/packages/registry/src/tests/test_registerable.cairo @@ -86,6 +86,7 @@ fn test_registrable_update() { .update( WORLD_ADDRESS, NAMEPSACE, + PROJECT, PRESET, color, Option::None,