diff --git a/client/src/effect/atoms/index.ts b/client/src/effect/atoms/index.ts index a65728ef..d8bd8694 100644 --- a/client/src/effect/atoms/index.ts +++ b/client/src/effect/atoms/index.ts @@ -150,19 +150,17 @@ export { trophiesAtom, trophiesDataAtom, type TrophyProject, - type TrophyMeta, type TrophyItem, type Trophy, -} from "./trophies"; +} from "./trophies-grpc"; export { progressionsAtom, progressionsDataAtom, type ProgressionProject, - type ProgressionMeta, type ProgressionItem, type Progress, -} from "./progressions"; +} from "./progressions-grpc"; export { holdersAtom, diff --git a/client/src/effect/atoms/progressions-grpc.ts b/client/src/effect/atoms/progressions-grpc.ts new file mode 100644 index 00000000..1f513f0c --- /dev/null +++ b/client/src/effect/atoms/progressions-grpc.ts @@ -0,0 +1,116 @@ +import { Atom } from "@effect-atom/atom-react"; +import { Effect } from "effect"; +import { ToriiGrpcClient, toriiRuntime } from "../runtime"; +import { Progress } from "@/models"; +import { mapResult } from "../utils/result"; +import type { Progressions } from "@/lib/achievements"; +import type { PlayerAchievementsPage } from "@dojoengine/grpc"; + +export type ProgressionProject = { + project: string; + namespace: string; +}; + +export type ProgressionItem = { + project: string; + progressions: { [key: string]: Progress }; +}; + +const fetchProgressionsEffect = (projects: ProgressionProject[]) => + Effect.gen(function* () { + if (projects.length === 0) return []; + + const { client } = yield* ToriiGrpcClient; + + const namespaces = projects.map((p) => p.namespace); + + const response: PlayerAchievementsPage = yield* Effect.tryPromise(() => + client.getPlayerAchievements({ + world_addresses: [], + namespaces, + player_addresses: [], + pagination: { + limit: 10000, + cursor: undefined, + direction: "Forward", + order_by: [], + }, + }), + ); + + const progressionsByProject: { + [project: string]: { [key: string]: Progress }; + } = {}; + + for (const project of projects) { + progressionsByProject[project.project] = {}; + } + + for (const playerEntry of response.items) { + const playerAddress = playerEntry.player_address; + + for (const achievementProgress of playerEntry.achievements) { + const achievement = achievementProgress.achievement; + if (!achievement) continue; + + const project = projects.find( + (p) => p.namespace === achievement.namespace, + ); + if (!project) continue; + + for (const taskProgress of achievementProgress.task_progress) { + const progress = Progress.fromGrpc( + playerAddress, + achievement, + taskProgress, + playerEntry.stats?.last_achievement_at, + ); + progressionsByProject[project.project][progress.key] = progress; + } + } + } + + const result: ProgressionItem[] = projects.map((p) => ({ + project: p.project, + progressions: progressionsByProject[p.project], + })); + + return result; + }); + +const progressionsFamily = Atom.family((key: string) => { + const projects: ProgressionProject[] = JSON.parse(key); + return toriiRuntime + .atom(fetchProgressionsEffect(projects)) + .pipe(Atom.keepAlive); +}); + +export const progressionsAtom = (projects: ProgressionProject[]) => { + const sorted = [...projects].sort((a, b) => + a.project.localeCompare(b.project), + ); + return progressionsFamily(JSON.stringify(sorted)); +}; + +const progressionsDataFamily = Atom.family((key: string) => { + const baseAtom = progressionsFamily(key); + return baseAtom.pipe( + Atom.map((result) => + mapResult(result, (items) => + items.reduce((acc, item) => { + acc[item.project] = item.progressions; + return acc; + }, {} as Progressions), + ), + ), + ); +}); + +export const progressionsDataAtom = (projects: ProgressionProject[]) => { + const sorted = [...projects].sort((a, b) => + a.project.localeCompare(b.project), + ); + return progressionsDataFamily(JSON.stringify(sorted)); +}; + +export type { Progress }; diff --git a/client/src/effect/atoms/trophies-grpc.ts b/client/src/effect/atoms/trophies-grpc.ts new file mode 100644 index 00000000..7fc637a8 --- /dev/null +++ b/client/src/effect/atoms/trophies-grpc.ts @@ -0,0 +1,97 @@ +import { Atom } from "@effect-atom/atom-react"; +import { Effect } from "effect"; +import { ToriiGrpcClient, toriiRuntime } from "../runtime"; +import { Trophy } from "@/models"; +import { mapResult } from "../utils/result"; +import type { Trophies } from "@/lib/achievements"; +import type { AchievementsPage } from "@dojoengine/grpc"; + +export type TrophyProject = { + project: string; + namespace: string; +}; + +export type TrophyItem = { + project: string; + trophies: { [id: string]: Trophy }; +}; + +const fetchTrophiesEffect = (projects: TrophyProject[]) => + Effect.gen(function* () { + if (projects.length === 0) return []; + + const { client } = yield* ToriiGrpcClient; + + const namespaces = projects.map((p) => p.namespace); + + const response: AchievementsPage = yield* Effect.tryPromise(() => + client.getAchievements({ + world_addresses: [], + namespaces, + hidden: undefined, + pagination: { + limit: 1000, + cursor: undefined, + direction: "Forward", + order_by: [], + }, + }), + ); + + const trophiesByProject: { [project: string]: { [id: string]: Trophy } } = + {}; + + for (const project of projects) { + trophiesByProject[project.project] = {}; + } + + for (const item of response.items) { + const project = projects.find((p) => p.namespace === item.namespace); + if (!project) continue; + + const trophy = Trophy.fromGrpc(item); + trophiesByProject[project.project][trophy.id] = trophy; + } + + const result: TrophyItem[] = projects.map((p) => ({ + project: p.project, + trophies: trophiesByProject[p.project], + })); + + return result; + }); + +const trophiesFamily = Atom.family((key: string) => { + const projects: TrophyProject[] = JSON.parse(key); + return toriiRuntime.atom(fetchTrophiesEffect(projects)).pipe(Atom.keepAlive); +}); + +export const trophiesAtom = (projects: TrophyProject[]) => { + const sorted = [...projects].sort((a, b) => + a.project.localeCompare(b.project), + ); + return trophiesFamily(JSON.stringify(sorted)); +}; + +const trophiesDataFamily = Atom.family((key: string) => { + const baseAtom = trophiesFamily(key); + return baseAtom.pipe( + Atom.map((result) => + mapResult(result, (items) => + items.reduce((acc, item) => { + acc[item.project] = item.trophies; + return acc; + }, {} as Trophies), + ), + ), + ); +}); + +export const trophiesDataAtom = (projects: TrophyProject[]) => { + const sorted = [...projects].sort((a, b) => + a.project.localeCompare(b.project), + ); + return trophiesDataFamily(JSON.stringify(sorted)); +}; + +export type { Trophy }; diff --git a/client/src/effect/hooks/achievements.ts b/client/src/effect/hooks/achievements.ts index 8e48731b..20296eda 100644 --- a/client/src/effect/hooks/achievements.ts +++ b/client/src/effect/hooks/achievements.ts @@ -5,17 +5,15 @@ import { type TrophyProject, type TrophyItem, type Trophy, -} from "../atoms/trophies"; +} from "../atoms/trophies-grpc"; import { progressionsDataAtom, type ProgressionProject, type ProgressionItem, type Progress, -} from "../atoms/progressions"; +} from "../atoms/progressions-grpc"; import { editionsAtom } from "../atoms/registry"; import { unwrapOr } from "../utils/result"; -import { getSelectorFromTag } from "@/models"; -import { TROPHY, PROGRESS } from "@/constants"; import type { Progressions, Trophies } from "@/lib/achievements"; const useTrophyProjects = (): TrophyProject[] => { @@ -27,7 +25,6 @@ const useTrophyProjects = (): TrophyProject[] => { editions.map((e) => ({ project: e.config.project, namespace: e.namespace as string, - model: getSelectorFromTag(e.namespace as string, TROPHY), })), [editions], ); @@ -42,7 +39,6 @@ const useProgressionProjects = (): ProgressionProject[] => { editions.map((e) => ({ project: e.config.project, namespace: e.namespace as string, - model: getSelectorFromTag(e.namespace as string, PROGRESS), })), [editions], ); diff --git a/client/src/models/progress.ts b/client/src/models/progress.ts index 0c374078..9e454afe 100644 --- a/client/src/models/progress.ts +++ b/client/src/models/progress.ts @@ -1,3 +1,13 @@ +import type { + PlayerAchievementsPage, + AchievementsPage, +} from "@dojoengine/grpc"; + +type GrpcPlayerEntry = PlayerAchievementsPage["items"][number]; +type GrpcPlayerProgress = GrpcPlayerEntry["achievements"][number]; +type GrpcTaskProgress = GrpcPlayerProgress["task_progress"][number]; +type GrpcAchievement = AchievementsPage["items"][number]; + export interface RawProgress { achievementId: string; playerId: string; @@ -54,4 +64,26 @@ export class Progress { timestamp: new Date(node.completionTime).getTime() / 1000, }; } + + static fromGrpc( + playerAddress: string, + achievement: GrpcAchievement, + taskProgress: GrpcTaskProgress, + completedAt?: number, + ): Progress { + const task = achievement.tasks.find( + (t) => t.task_id === taskProgress.task_id, + ); + + return { + key: `${playerAddress}-${achievement.id}-${taskProgress.task_id}`, + achievementId: achievement.id, + playerId: playerAddress, + points: achievement.points, + taskId: taskProgress.task_id, + taskTotal: task?.total ?? 0, + total: taskProgress.count, + timestamp: completedAt ?? 0, + }; + } } diff --git a/client/src/models/trophy.ts b/client/src/models/trophy.ts index 0c88eff1..f58fd5aa 100644 --- a/client/src/models/trophy.ts +++ b/client/src/models/trophy.ts @@ -1,4 +1,8 @@ import { shortString } from "starknet"; +import type { AchievementsPage } from "@dojoengine/grpc"; + +type GrpcAchievement = AchievementsPage["items"][number]; +type GrpcAchievementTask = GrpcAchievement["tasks"][number]; export interface RawTrophy { id: string; @@ -95,4 +99,33 @@ export class Trophy { data: node.data, }; } + + static fromGrpc(achievement: GrpcAchievement): Trophy { + const tasks: Task[] = achievement.tasks.map( + (task: GrpcAchievementTask) => ({ + id: task.task_id, + total: task.total, + description: task.description, + }), + ); + + const firstTaskId = tasks[0]?.id ?? ""; + + return { + key: `${achievement.id}-${firstTaskId}`, + id: achievement.id, + hidden: achievement.hidden, + index: achievement.index, + earning: achievement.points, + start: + achievement.start === "0" ? 0 : Number.parseInt(achievement.start, 10), + end: achievement.end === "0" ? 0 : Number.parseInt(achievement.end, 10), + group: achievement.group, + icon: achievement.icon, + title: achievement.title, + description: achievement.description, + tasks, + data: achievement.data ?? "", + }; + } }