diff --git a/packages/controller/src/iframe/profile.ts b/packages/controller/src/iframe/profile.ts index a484fec67..dc8b6e739 100644 --- a/packages/controller/src/iframe/profile.ts +++ b/packages/controller/src/iframe/profile.ts @@ -24,10 +24,12 @@ export class ProfileIFrame extends IFrame { const _url = new URL( slot ? namespace - ? `${_profileUrl}/account/${username}/slot/${slot}?ns=${encodeURIComponent( - namespace, + ? `${_profileUrl}/account/${username}/slot/${slot}?ps=${encodeURIComponent( + slot, + )}&ns=${encodeURIComponent(namespace)}` + : `${_profileUrl}/account/${username}/slot/${slot}?ps=${encodeURIComponent( + slot, )}` - : `${_profileUrl}/account/${username}/slot/${slot}` : `${_profileUrl}/account/${username}`, ); diff --git a/packages/profile/src/components/achievements/trophies.tsx b/packages/profile/src/components/achievements/trophies.tsx index cc4849cd6..fcde67c60 100644 --- a/packages/profile/src/components/achievements/trophies.tsx +++ b/packages/profile/src/components/achievements/trophies.tsx @@ -121,7 +121,7 @@ function Group({ 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); diff --git a/packages/profile/src/components/context/connection.tsx b/packages/profile/src/components/context/connection.tsx index c8ff4f41d..86c9970d6 100644 --- a/packages/profile/src/components/context/connection.tsx +++ b/packages/profile/src/components/context/connection.tsx @@ -21,6 +21,7 @@ type ConnectionContextType = { provider: RpcProvider; chainId: string; erc20: string[]; + project?: string; namespace?: string; isVisible: boolean; setIsVisible: (isVisible: boolean) => void; @@ -34,7 +35,7 @@ type ParentMethods = { }; const initialState: ConnectionContextType = { - origin: "", + origin: location.origin, parent: { close: () => {}, openSettings: () => {}, @@ -65,6 +66,11 @@ export function ConnectionProvider({ children }: { children: ReactNode }) { }); } + const psParam = searchParams.get("ps"); + if (psParam && !state.project) { + state.project = decodeURIComponent(psParam); + } + const nsParam = searchParams.get("ns"); if (nsParam && !state.namespace) { state.namespace = decodeURIComponent(nsParam); diff --git a/packages/profile/src/hooks/achievements.ts b/packages/profile/src/hooks/achievements.ts index b15135b00..be51095f8 100644 --- a/packages/profile/src/hooks/achievements.ts +++ b/packages/profile/src/hooks/achievements.ts @@ -1,13 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import { TROPHY, PROGRESS } from "@/constants"; -import { useEvents } from "./events"; -import { Trophy, Progress } from "@/models"; -import { Task } from "@/components/achievements/trophy"; +import { Trophy, Progress, Task } from "@/models"; import { useConnection } from "./context"; import { useAccount } from "./account"; - -// Number of events to fetch at a time, could be increased if needed -const LIMIT = 1000; +import { useProgressions } from "./progressions"; +import { useTrophies } from "./trophies"; +import { Task as ItemTask } from "@/components/achievements/trophy"; export interface Item { id: string; @@ -22,7 +20,7 @@ export interface Item { percentage: string; completed: boolean; pinned: boolean; - tasks: Task[]; + tasks: ItemTask[]; } export interface Counters { @@ -41,7 +39,7 @@ export interface Player { } export function useAchievements(accountAddress?: string) { - const { namespace } = useConnection(); + const { project, namespace } = useConnection(); const { address } = useAccount(); const [isLoading, setIsLoading] = useState(true); @@ -52,158 +50,160 @@ export function useAchievements(accountAddress?: string) { return accountAddress || address; }, [accountAddress, address]); - const { events: trophies, isFetching: isFetchingTrophiess } = - useEvents({ - namespace: namespace || "", + const { trophies: rawTrophies, isFetching: isFetchingTrophies } = useTrophies( + { + namespace: namespace ?? "", name: TROPHY, - limit: LIMIT, - parse: Trophy.parse, - }); - const { events: progresses, isFetching: isFetchingProgresses } = - useEvents({ - namespace: namespace || "", + project: project ?? "", + parser: Trophy.parse, + }, + ); + + const { progressions: rawProgressions, isFetching: isFetchingProgressions } = + useProgressions({ + namespace: namespace ?? "", name: PROGRESS, - limit: LIMIT, - parse: Progress.parse, + project: project ?? "", + parser: Progress.parse, }); // Compute achievements and players useEffect(() => { if ( - isFetchingTrophiess || - isFetchingProgresses || - !trophies.length || + isFetchingTrophies || + isFetchingProgressions || + !rawTrophies.length || !currentAddress ) return; - // Compute counters - const counters: Counters = {}; - progresses.forEach(({ player, task, count, timestamp }) => { - counters[player] = counters[player] || {}; - counters[player][task] = counters[player][task] || []; - counters[player][task].push({ count, timestamp }); + // Merge trophies + const trophies: { [id: string]: Trophy } = {}; + 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; + } }); // Compute players and achievement stats - const dedupedTrophies = trophies.filter( - (trophy, index) => - trophies.findIndex((t) => t.id === trophy.id) === index, - ); + const data: { + [playerId: string]: { + [achievementId: string]: { + [taskId: string]: { + completion: boolean; + timestamp: number; + count: number; + }; + }; + }; + } = {}; + rawProgressions.forEach((progress: Progress) => { + const { achievementId, playerId, taskId, taskTotal, total, timestamp } = + progress; + + // Compute player + const detaultTasks: { [taskId: string]: boolean } = {}; + trophies[achievementId].tasks.forEach((task: Task) => { + detaultTasks[task.id] = false; + }); + data[playerId] = data[playerId] || {}; + data[playerId][achievementId] = + data[playerId][achievementId] || detaultTasks; + data[playerId][achievementId][taskId] = { + completion: total >= taskTotal, + timestamp, + count: total, + }; + }); + const stats: Stats = {}; - const players: Player[] = Object.keys(counters) - .map((playerAddress) => { - let timestamp = 0; - const completeds: string[] = []; - const earnings = dedupedTrophies.reduce( - (total: number, trophy: Trophy) => { - // Compute at which timestamp the latest achievement was completed - let completed = true; - trophy.tasks.forEach((task) => { - let count = 0; - let completion = false; - counters[playerAddress]?.[task.id] - ?.sort((a, b) => a.timestamp - b.timestamp) - .forEach( - ({ - count: c, - timestamp: t, - }: { - count: number; - timestamp: number; - }) => { - count += c; - if (!completion && count >= task.total) { - timestamp = t > timestamp ? t : timestamp; - completion = true; - } - }, - ); - completed = completed && completion; - }); - // Add trophy to list if completed - if (completed) completeds.push(trophy.id); - // Update stats - stats[trophy.id] = stats[trophy.id] || 0; - stats[trophy.id] += completed ? 1 : 0; - return completed ? total + trophy.earning : total; - }, - 0, + const players: Player[] = []; + Object.keys(data).forEach((playerId) => { + const player = data[playerId]; + const completeds: string[] = []; + let timestamp = 0; + const earnings = Object.keys(player).reduce((acc, achievementId) => { + const completion = Object.values(player[achievementId]).every( + (task) => task.completion, ); - return { - address: playerAddress, - earnings, - timestamp, - completeds, - }; - }) - .sort((a, b) => a.timestamp - b.timestamp) // Oldest to newest - .sort((a, b) => b.earnings - a.earnings); // Highest to lowest - setPlayers(players); + timestamp = Math.max( + ...Object.values(player[achievementId]).map((task) => task.timestamp), + ); + if (completion) { + completeds.push(achievementId); + stats[achievementId] = stats[achievementId] || 0; + stats[achievementId] += 1; + } + return acc + (completion ? trophies[achievementId].earning : 0); + }, 0); + players.push({ + address: playerId, + earnings, + timestamp: timestamp, + completeds, + }); + }); + + setPlayers( + Object.values(players) + .sort((a, b) => a.timestamp - b.timestamp) // Oldest to newest + .sort((a, b) => b.earnings - a.earnings), // Highest to lowest + ); // Compute achievements - const achievements: Item[] = dedupedTrophies - .map((trophy) => { - // Compute at which timestamp the achievement was completed - let timestamp = 0; - let completed = true; - const tasks: Task[] = []; - trophy.tasks.forEach((task) => { - let count = 0; - let completion = false; - counters[currentAddress]?.[task.id] - ?.sort((a, b) => a.timestamp - b.timestamp) - .forEach( - ({ - count: c, - timestamp: t, - }: { - count: number; - timestamp: number; - }) => { - count += c; - if (!completion && count >= task.total) { - timestamp = t; - completion = true; - } - }, - ); - tasks.push({ - id: task.id, - count, - total: task.total, - description: task.description, - }); - completed = completed && completion; - }); + const achievements: Item[] = Object.values(trophies).map( + (trophy: Trophy) => { + const achievement = data[currentAddress]?.[trophy.id] || {}; + const completion = + Object.values(achievement).length > 0 && + Object.values(achievement).every((task) => task.completion); + const timestamp = Math.max( + ...Object.values(achievement).map((task) => task.timestamp), + ); // Compute percentage of players who completed the achievement const percentage = ( - players.length ? (100 * stats[trophy.id]) / players.length : 0 + players.length ? (100 * (stats[trophy.id] ?? 0)) / players.length : 0 ).toFixed(0); + const tasks: ItemTask[] = trophy.tasks.map((task) => { + return { + ...task, + count: achievement[task.id]?.count || 0, + }; + }); return { - ...trophy, - completed, + id: trophy.id, + hidden: trophy.hidden, + index: trophy.index, + earning: trophy.earning, + group: trophy.group, + icon: trophy.icon, + title: trophy.title, + description: trophy.description, + completed: completion, percentage, timestamp, pinned: false, tasks, }; - }) - .sort((a, b) => a.index - b.index) // Lowest index to greatest - .sort((a, b) => (a.id > b.id ? 1 : -1)) // A to Z - .sort((a, b) => (b.hidden ? -1 : 1) - (a.hidden ? -1 : 1)) // Visible to hidden - .sort((a, b) => b.timestamp - a.timestamp) // Newest to oldest - .sort((a, b) => (b.completed ? 1 : 0) - (a.completed ? 1 : 0)); // Completed to uncompleted - setAchievements(achievements); + }, + ); + setAchievements( + achievements + .sort((a, b) => a.index - b.index) // Lowest index to greatest + .sort((a, b) => (a.id > b.id ? 1 : -1)) // A to Z + .sort((a, b) => (b.hidden ? -1 : 1) - (a.hidden ? -1 : 1)) // Visible to hidden + .sort((a, b) => b.timestamp - a.timestamp) // Newest to oldest + .sort((a, b) => (b.completed ? 1 : 0) - (a.completed ? 1 : 0)), // Completed to uncompleted + ); // Update loading state setIsLoading(false); - }, [ - currentAddress, - trophies, - progresses, - isFetchingTrophiess, - isFetchingProgresses, - ]); + }, [currentAddress, isFetchingTrophies, isFetchingProgressions]); return { achievements, players, isLoading }; } diff --git a/packages/profile/src/hooks/progressions.ts b/packages/profile/src/hooks/progressions.ts new file mode 100644 index 000000000..cec3a1c77 --- /dev/null +++ b/packages/profile/src/hooks/progressions.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; +import { Project, useProgressionsQuery } from "@cartridge/utils/api/cartridge"; +import { Progress, RawProgress, getSelectorFromTag } from "@/models"; + +interface Response { + items: { achievements: RawProgress[] }[]; +} + +export function useProgressions({ + namespace, + name, + project, + parser, +}: { + namespace: string; + name: string; + project: string; + parser: (node: RawProgress) => Progress; +}) { + const [progressions, setProgressions] = useState<{ [key: string]: Progress }>( + {}, + ); + + // Fetch achievement creations from raw events + const projects: Project[] = [ + { model: getSelectorFromTag(namespace, name), namespace, project }, + ]; + const { refetch: fetchProgressions, isFetching } = useProgressionsQuery( + { + projects, + }, + { + enabled: !!namespace && !!project, + refetchInterval: 30_000, // Refetch every 30 seconds + onSuccess: ({ playerAchievements }: { playerAchievements: Response }) => { + const progressions = playerAchievements.items[0].achievements + .map(parser) + .reduce((acc: { [key: string]: Progress }, achievement: Progress) => { + acc[achievement.key] = achievement; + return acc; + }, {}); + setProgressions((previous) => ({ ...previous, ...progressions })); + }, + }, + ); + + useEffect(() => { + if (!namespace || !project) return; + try { + fetchProgressions(); + } catch (error) { + // Could happen if the indexer is down or wrong url + console.error(error); + } + }, [namespace, project, fetchProgressions]); + + return { progressions: Object.values(progressions), isFetching }; +} diff --git a/packages/profile/src/hooks/trophies.ts b/packages/profile/src/hooks/trophies.ts new file mode 100644 index 000000000..4466fdd96 --- /dev/null +++ b/packages/profile/src/hooks/trophies.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { Project, useAchievementsQuery } from "@cartridge/utils/api/cartridge"; +import { RawTrophy, Trophy, getSelectorFromTag } from "@/models"; + +interface Response { + items: { achievements: RawTrophy[] }[]; +} + +export function useTrophies({ + namespace, + name, + project, + parser, +}: { + namespace: string; + name: string; + project: string; + parser: (node: RawTrophy) => Trophy; +}) { + const [trophies, setTrophies] = useState<{ [key: string]: Trophy }>({}); + + // Fetch achievement creations from raw events + const projects: Project[] = [ + { model: getSelectorFromTag(namespace, name), namespace, project }, + ]; + const { refetch: fetchAchievements, isFetching } = useAchievementsQuery( + { + projects, + }, + { + enabled: !!namespace && !!project, + refetchInterval: 300_000, // Refetch every 5 minutes + onSuccess: ({ achievements }: { achievements: Response }) => { + const trophies = achievements.items[0].achievements + .map(parser) + .reduce((acc: { [key: string]: Trophy }, achievement: Trophy) => { + acc[achievement.key] = achievement; + return acc; + }, {}); + setTrophies((previous) => ({ ...trophies, ...previous })); + }, + }, + ); + + useEffect(() => { + if (!namespace || !project) return; + try { + fetchAchievements(); + } catch (error) { + // Could happen if the indexer is down or wrong url + console.error(error); + } + }, [namespace, project, fetchAchievements]); + + return { trophies: Object.values(trophies), isFetching }; +} diff --git a/packages/profile/src/models/index.ts b/packages/profile/src/models/index.ts index 2c4d8ed1f..2212bf9c1 100644 --- a/packages/profile/src/models/index.ts +++ b/packages/profile/src/models/index.ts @@ -1,3 +1,29 @@ -export * from "./reader"; +import { ByteArray, byteArray, hash } from "starknet"; + export * from "./trophy"; export * from "./progress"; + +// Computes dojo selector from namespace and event name +export function getSelectorFromTag(namespace: string, event: string): string { + return hash.computePoseidonHashOnElements([ + computeByteArrayHash(namespace), + computeByteArrayHash(event), + ]); +} + +// Poseidon hash of a string representated as a ByteArray +export function computeByteArrayHash(str: string): string { + const bytes = byteArray.byteArrayFromString(str); + return hash.computePoseidonHashOnElements(serializeByteArray(bytes)); +} + +// Serializes a ByteArray to a bigint array +export function serializeByteArray(byteArray: ByteArray): bigint[] { + const result: bigint[] = [ + BigInt(byteArray.data.length), + ...byteArray.data.map((word) => BigInt(word.toString())), + BigInt(byteArray.pending_word), + BigInt(byteArray.pending_word_len), + ]; + return result; +} diff --git a/packages/profile/src/models/progress.ts b/packages/profile/src/models/progress.ts index f37139114..0c3740781 100644 --- a/packages/profile/src/models/progress.ts +++ b/packages/profile/src/models/progress.ts @@ -1,52 +1,57 @@ -import { EventNode } from "@cartridge/utils/api/indexer"; -import { Reader } from "./reader"; +export interface RawProgress { + achievementId: string; + playerId: string; + points: number; + taskId: string; + taskTotal: number; + total: number; + completionTime: number; +} export class Progress { - player: string; - task: string; - count: number; + key: string; + achievementId: string; + playerId: string; + points: number; + taskId: string; + taskTotal: number; + total: number; timestamp: number; - constructor(player: string, task: string, count: number, timestamp: number) { - this.player = player; - this.task = task; - this.count = count; + constructor( + key: string, + achievementId: string, + playerId: string, + points: number, + taskId: string, + taskTotal: number, + total: number, + timestamp: number, + ) { + this.key = key; + this.achievementId = achievementId; + this.playerId = playerId; + this.points = points; + this.taskId = taskId; + this.taskTotal = taskTotal; + this.total = total; this.timestamp = timestamp; } - static from(node: EventNode): Progress { + static from(node: RawProgress): Progress { return Progress.parse(node); } - static parse(node: EventNode): Progress { - const reader = new ProgressReader(node); + static parse(node: RawProgress): Progress { return { - player: reader.popPlayer(), - task: reader.popTask(), - count: reader.popCount(), - timestamp: reader.popTimestamp(), + key: `${node.playerId}-${node.achievementId}-${node.taskId}`, + achievementId: node.achievementId, + playerId: node.playerId, + points: node.points, + taskId: node.taskId, + taskTotal: node.taskTotal, + total: node.total, + timestamp: new Date(node.completionTime).getTime() / 1000, }; } } - -class ProgressReader extends Reader { - constructor(node: EventNode) { - super(node); - } - - popPlayer(): string { - return this.popValue(); - } - - popTask(): string { - return this.popString(2); - } - - popCount(): number { - return this.popNumber(); - } - - popTimestamp(): number { - return this.popNumber(); - } -} diff --git a/packages/profile/src/models/reader.ts b/packages/profile/src/models/reader.ts deleted file mode 100644 index 3933b4db8..000000000 --- a/packages/profile/src/models/reader.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { EventNode } from "@cartridge/utils/api/indexer"; -import { byteArray, shortString } from "starknet"; - -export class Reader { - node: EventNode; - cursor: number; - - constructor(node: EventNode) { - this.node = node; - this.cursor = 1; - } - - popValue(shift = 1): string { - const value = this.node.data[this.cursor]; - this.cursor += shift; - return value; - } - - popNumber(shift = 1): number { - return parseInt(this.popValue(shift)); - } - - popBoolean(shift = 1): boolean { - const value = this.popValue(shift); - return parseInt(value) ? false : true; - } - - popString(shift = 1): string { - const value = this.popValue(shift); - return shortString.decodeShortString(value); - } - - popByteArray(shift = 1): string { - const length = this.popNumber(); - const value = byteArray.stringFromByteArray({ - data: length - ? this.node.data.slice(this.cursor, this.cursor + length) - : [], - pending_word: this.node.data[this.cursor + length], - pending_word_len: this.node.data[this.cursor + length + 1], - }); - this.cursor += length + 1 + shift; - return value; - } -} diff --git a/packages/profile/src/models/trophy.ts b/packages/profile/src/models/trophy.ts index b47daac2c..1cc866ecf 100644 --- a/packages/profile/src/models/trophy.ts +++ b/packages/profile/src/models/trophy.ts @@ -1,5 +1,21 @@ -import { EventNode } from "@cartridge/utils/api/indexer"; -import { Reader } from "."; +import { shortString } from "starknet"; + +export interface RawTrophy { + id: string; + hidden: number; + page: number; + points: number; + start: string; + end: string; + achievementGroup: string; + icon: string; + title: string; + description: string; + taskId: string; + taskTotal: number; + taskDescription: string; + data: string; +} export interface Task { id: string; @@ -8,6 +24,7 @@ export interface Task { } export class Trophy { + key: string; id: string; hidden: boolean; index: number; @@ -22,6 +39,7 @@ export class Trophy { data: string; constructor( + key: string, id: string, hidden: boolean, index: number, @@ -35,6 +53,7 @@ export class Trophy { tasks: Task[], data: string, ) { + this.key = key; this.id = id; this.hidden = hidden; this.index = index; @@ -49,105 +68,31 @@ export class Trophy { this.data = data; } - static from(node: EventNode): Trophy { + static from(node: RawTrophy): Trophy { return Trophy.parse(node); } - static parse(node: EventNode): Trophy { - try { - return Trophy.parseV0(node); - } catch { - return Trophy.parseV1(node); - } - } - - static parseV1(node: EventNode): Trophy { - const reader = new TrophyReader(node); + static parse(node: RawTrophy): Trophy { return { - id: reader.popId(), - hidden: reader.popHidden(), - index: reader.popIndex(), - earning: reader.popEarning(), - start: reader.popNumber(), - end: reader.popNumber(), - group: reader.popString(), - icon: reader.popString(), - title: reader.popString(), - description: reader.popByteArray(), - tasks: reader.popTasks(), - data: "", + key: `${node.id}-${node.taskId}`, + id: node.id, + hidden: node.hidden === 1, + index: node.page, + earning: node.points, + start: node.start === "0x" ? 0 : parseInt(node.start), + end: node.end === "0x" ? 0 : parseInt(node.end), + group: shortString.decodeShortString(node.achievementGroup), + icon: shortString.decodeShortString(node.icon), + title: shortString.decodeShortString(node.title), + description: node.description, + tasks: [ + { + id: node.taskId, + total: node.taskTotal, + description: node.taskDescription, + }, + ], + data: node.data, }; } - - static parseV0(node: EventNode): Trophy { - const reader = new TrophyReader(node); - return { - id: reader.popId(), - hidden: reader.popHidden(), - index: reader.popIndex(), - earning: reader.popEarning(), - start: 0, - end: 0, - group: reader.popGroup(), - icon: reader.popIcon(), - title: reader.popTitle(), - description: reader.popDescription(), - tasks: reader.popTasks(), - data: "", - }; - } -} - -class TrophyReader extends Reader { - constructor(node: EventNode) { - super(node); - } - - popId(): string { - return this.popString(2); - } - - popHidden(): boolean { - return !this.popBoolean(); - } - - popIndex(): number { - return this.popNumber(); - } - - popEarning(): number { - return this.popNumber(); - } - - popGroup(): string { - return this.popString(); - } - - popIcon(): string { - return this.popString(); - } - - popTitle(): string { - return this.popString(); - } - - popDescription(): string { - return this.popByteArray(); - } - - popTasks(): Task[] { - const tasks = []; - const length = this.popNumber(); - for (let i = 0; i < length; i++) { - tasks.push(this.popTask()); - } - return tasks; - } - - popTask(): Task { - const id = this.popString(); - const total = this.popNumber(); - const description = this.popDescription(); - return { id, total, description }; - } } diff --git a/packages/utils/src/api/cartridge/achievements.graphql b/packages/utils/src/api/cartridge/achievements.graphql new file mode 100644 index 000000000..4f80a705a --- /dev/null +++ b/packages/utils/src/api/cartridge/achievements.graphql @@ -0,0 +1,50 @@ +query Achievements($projects: [Project!]!) { + achievements(projects: $projects) { + items { + meta { + project + model + namespace + count + } + achievements { + id + hidden + page + points + start + end + achievementGroup + icon + title + description + taskId + taskTotal + taskDescription + data + } + } + } +} + +query Progressions($projects: [Project!]!) { + playerAchievements(projects: $projects) { + items { + meta { + project + model + namespace + count + } + achievements { + playerId + achievementId + points + taskId + taskTotal + total + completionTime + } + } + } +}