diff --git a/open-api/openapi.json b/open-api/openapi.json index 32fdf293..01daa795 100644 --- a/open-api/openapi.json +++ b/open-api/openapi.json @@ -1809,72 +1809,44 @@ "GlobalStat": { "type": "object", "properties": { + "value": { + "type": "number" + }, "rank": { "type": "integer", + "nullable": true, "minimum": 0, "exclusiveMinimum": true }, - "value": { - "type": "number" - }, "percentile": { "type": "number", + "nullable": true, "minimum": 0, "maximum": 1 } }, - "required": ["rank", "value", "percentile"] + "required": ["value", "rank", "percentile"] }, "PlayerProfileGlobalStats": { "type": "object", "properties": { "clears": { - "allOf": [ - { - "$ref": "#/components/schemas/GlobalStat" - } - ], - "nullable": true + "$ref": "#/components/schemas/GlobalStat" }, "freshClears": { - "allOf": [ - { - "$ref": "#/components/schemas/GlobalStat" - } - ], - "nullable": true + "$ref": "#/components/schemas/GlobalStat" }, "sherpas": { - "allOf": [ - { - "$ref": "#/components/schemas/GlobalStat" - } - ], - "nullable": true + "$ref": "#/components/schemas/GlobalStat" }, "totalTimePlayed": { - "allOf": [ - { - "$ref": "#/components/schemas/GlobalStat" - } - ], - "nullable": true + "$ref": "#/components/schemas/GlobalStat" }, "contest": { - "allOf": [ - { - "$ref": "#/components/schemas/GlobalStat" - } - ], - "nullable": true + "$ref": "#/components/schemas/GlobalStat" }, "sumOfBest": { - "allOf": [ - { - "$ref": "#/components/schemas/GlobalStat" - } - ], - "nullable": true + "$ref": "#/components/schemas/GlobalStat" } }, "required": [ diff --git a/src/routes/leaderboard/individual/global.ts b/src/routes/leaderboard/individual/global.ts index 6a19a6d7..3e9c2ccb 100644 --- a/src/routes/leaderboard/individual/global.ts +++ b/src/routes/leaderboard/individual/global.ts @@ -9,10 +9,6 @@ import { getIndividualGlobalLeaderboard, searchIndividualGlobalLeaderboard } from "@/services/leaderboard/individual/global" -import { - getIndividualWorldFirstPowerRankingsLeaderboard, - searchIndividualWorldFirstPowerRankingsLeaderboard -} from "@/services/leaderboard/individual/power-rankings" import { z } from "zod" export const leaderboardIndividualGlobalRoute = new RaidHubRoute({ @@ -44,16 +40,11 @@ export const leaderboardIndividualGlobalRoute = new RaidHubRoute({ const { page, count, search } = req.query if (search) { - const data = await (category === "world-first-rankings" - ? searchIndividualWorldFirstPowerRankingsLeaderboard({ - membershipId: search, - take: count - }) - : searchIndividualGlobalLeaderboard({ - membershipId: search, - take: count, - category - })) + const data = await searchIndividualGlobalLeaderboard({ + membershipId: search, + take: count, + category + }) if (!data) { return RaidHubRoute.fail(ErrorCode.PlayerNotOnLeaderboardError, { @@ -69,16 +60,11 @@ export const leaderboardIndividualGlobalRoute = new RaidHubRoute({ entries: data.entries }) } else { - const entries = await (category === "world-first-rankings" - ? getIndividualWorldFirstPowerRankingsLeaderboard({ - skip: (page - 1) * count, - take: count - }) - : getIndividualGlobalLeaderboard({ - skip: (page - 1) * count, - take: count, - category - })) + const entries = await getIndividualGlobalLeaderboard({ + skip: (page - 1) * count, + take: count, + category + }) return RaidHubRoute.ok({ type: "individual" as const, diff --git a/src/routes/player/membershipId/profile.ts b/src/routes/player/membershipId/profile.ts index f630da49..7dc3c58d 100644 --- a/src/routes/player/membershipId/profile.ts +++ b/src/routes/player/membershipId/profile.ts @@ -71,17 +71,14 @@ This is used to hydrate the RaidHub profile page`, const [activityStats, globalStats, worldFirstEntries] = await statsPromises + if (!globalStats) { + throw new Error(`Unexpected error: global stats for player ${membershipId} not found`) + } + return RaidHubRoute.ok({ playerInfo: player, stats: { - global: globalStats ?? { - clears: null, - freshClears: null, - sherpas: null, - sumOfBest: null, - totalTimePlayed: null, - contest: null - }, + global: globalStats, activity: Object.fromEntries(activityStats.map(stat => [stat.activityId, stat])) }, worldFirstEntries: Object.fromEntries( diff --git a/src/schema/components/PlayerProfile.ts b/src/schema/components/PlayerProfile.ts index 17a1a4f3..715fcc87 100644 --- a/src/schema/components/PlayerProfile.ts +++ b/src/schema/components/PlayerProfile.ts @@ -19,9 +19,9 @@ export const zPlayerProfileActivityStats = registry.register( export const zGlobalStat = registry.register( "GlobalStat", z.object({ - rank: zNaturalNumber(), value: z.number(), - percentile: z.number().nonnegative().max(1) + rank: zNaturalNumber().nullable(), + percentile: z.number().nonnegative().max(1).nullable() }) ) @@ -29,12 +29,12 @@ export type PlayerProfileGlobalStats = z.input export const zPlayerProfileGlobalStats = registry.register( "PlayerProfileGlobalStats", z.object({ - clears: zGlobalStat.nullable(), - freshClears: zGlobalStat.nullable(), - sherpas: zGlobalStat.nullable(), - totalTimePlayed: zGlobalStat.nullable(), - contest: zGlobalStat.nullable(), - sumOfBest: zGlobalStat.nullable() + clears: zGlobalStat, + freshClears: zGlobalStat, + sherpas: zGlobalStat, + totalTimePlayed: zGlobalStat, + contest: zGlobalStat, + sumOfBest: zGlobalStat }) ) diff --git a/src/services/clans/clan-stats.ts b/src/services/clans/clan-stats.ts index b3531e1a..3a619190 100644 --- a/src/services/clans/clan-stats.ts +++ b/src/services/clans/clan-stats.ts @@ -12,10 +12,10 @@ export const getClanStats = async ( "ranked_scores" AS ( SELECT "membership_id", - COALESCE(wpr."score", 0) AS "score", - ROW_NUMBER() OVER (ORDER BY wpr."score" DESC) AS "intra_clan_ranking" + COALESCE(player."wfr_score", 0) AS "score", + ROW_NUMBER() OVER (ORDER BY player."wfr_score" DESC) AS "intra_clan_ranking" FROM membership_ids - LEFT JOIN "world_first_player_rankings" wpr USING (membership_id) + LEFT JOIN player USING (membership_id) ), "member_stats" AS ( SELECT diff --git a/src/services/leaderboard/individual/global.ts b/src/services/leaderboard/individual/global.ts index 7be6af31..edc96507 100644 --- a/src/services/leaderboard/individual/global.ts +++ b/src/services/leaderboard/individual/global.ts @@ -2,27 +2,20 @@ import { postgres } from "@/integrations/postgres" import { IndividualLeaderboardEntry } from "@/schema/components/LeaderboardData" import { IndividualGlobalLeaderboardCategory } from "@/schema/params/IndividualGlobalLeaderboardCategory" -export const individualGlobalLeaderboardSortColumns = [ - "clears", - "fresh_clears", - "sherpas", - "speed", - "total_time_played" -] as const - const categoryMap = { clears: "clears", "full-clears": "fresh_clears", sherpas: "sherpas", speedrun: "speed", - "in-raid-time": "total_time_played" + "in-raid-time": "total_time_played", + "world-first-rankings": "wfr_score" } as const const getColumn = (category: string) => { const column = categoryMap[category as keyof typeof categoryMap] - if (!individualGlobalLeaderboardSortColumns.includes(column)) { + if (!column) { // Just an extra layer of run-time validation to ensure that the column is one of the valid columns - throw new TypeError(`Invalid column: ${column}`) + throw new TypeError(`Invalid column: ${category}->${column}`) } return column } @@ -34,7 +27,7 @@ export const getIndividualGlobalLeaderboard = async ({ }: { skip: number take: number - category: Exclude + category: IndividualGlobalLeaderboardCategory }) => { const column = getColumn(category) @@ -72,7 +65,7 @@ export const searchIndividualGlobalLeaderboard = async ({ }: { membershipId: bigint | string take: number - category: Exclude + category: IndividualGlobalLeaderboardCategory }) => { const column = getColumn(category) diff --git a/src/services/leaderboard/individual/power-rankings.ts b/src/services/leaderboard/individual/power-rankings.ts deleted file mode 100644 index 3391e9f6..00000000 --- a/src/services/leaderboard/individual/power-rankings.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { postgres } from "@/integrations/postgres" -import { IndividualLeaderboardEntry } from "@/schema/components/LeaderboardData" - -export const getIndividualWorldFirstPowerRankingsLeaderboard = async ({ - skip, - take -}: { - skip: number - take: number -}) => { - return await postgres.queryRows( - `SELECT - world_first_player_rankings.position, - world_first_player_rankings.rank, - ROUND(world_first_player_rankings.score::numeric, 3) AS "value", - JSONB_BUILD_OBJECT( - 'membershipId', membership_id::text, - 'membershipType', membership_type, - 'iconPath', icon_path, - 'displayName', display_name, - 'bungieGlobalDisplayName', bungie_global_display_name, - 'bungieGlobalDisplayNameCode', bungie_global_display_name_code, - 'lastSeen', last_seen, - 'isPrivate', is_private, - 'cheatLevel', cheat_level - ) as "playerInfo" - FROM world_first_player_rankings - JOIN player USING (membership_id) - WHERE position > $1 AND position <= ($1 + $2) - ORDER BY position ASC`, - { - params: [skip, take], - fetchCount: take - } - ) -} - -export const searchIndividualWorldFirstPowerRankingsLeaderboard = async ({ - membershipId, - take -}: { - membershipId: bigint | string - take: number -}) => { - const result = await postgres.queryRow<{ position: number }>( - `SELECT position - FROM world_first_player_rankings - WHERE membership_id = $1::bigint - ORDER BY position ASC - LIMIT 1`, - { - params: [membershipId] - } - ) - if (!result) return null - - const page = Math.ceil(result.position / take) - return { - page, - entries: await getIndividualWorldFirstPowerRankingsLeaderboard({ - skip: (page - 1) * take, - take - }) - } -} diff --git a/src/services/leaderboard/power-rankings.test.ts b/src/services/leaderboard/power-rankings.test.ts deleted file mode 100644 index 81d5ccbc..00000000 --- a/src/services/leaderboard/power-rankings.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { zIndividualLeaderboardEntry } from "@/schema/components/LeaderboardData" -import { zNaturalNumber } from "@/schema/util" -import { - getIndividualWorldFirstPowerRankingsLeaderboard, - searchIndividualWorldFirstPowerRankingsLeaderboard -} from "@/services/leaderboard/individual/power-rankings" -import { describe, expect, it } from "bun:test" -import { z } from "zod" - -describe("getIndividualWorldFirstPowerRankingsLeaderboard", () => { - it("returns the correct shape", async () => { - const data = await getIndividualWorldFirstPowerRankingsLeaderboard({ - skip: 24921, - take: 27 - }).catch(console.error) - - const parsed = z.array(zIndividualLeaderboardEntry).safeParse(data) - if (!parsed.success) { - console.error(parsed.error.errors) - expect(parsed.error.errors).toEqual([]) - } else { - expect(parsed.data.length).toBeGreaterThan(0) - expect(parsed.success).toBe(true) - } - }) -}) - -describe("searchIndividualWorldFirstPowerRankingsLeaderboard", () => { - it("returns the correct shape", async () => { - const data = await searchIndividualWorldFirstPowerRankingsLeaderboard({ - take: 4, - membershipId: "4611686018488107374" - }).catch(console.error) - - const parsed = z - .object({ - page: zNaturalNumber(), - entries: z.array(zIndividualLeaderboardEntry) - }) - .safeParse(data) - if (!parsed.success) { - expect(parsed.error.errors).toEqual([]) - } else { - expect(parsed.data.entries.length).toBeGreaterThan(0) - expect(parsed.success).toBe(true) - } - }) -}) diff --git a/src/services/player.test.ts b/src/services/player.test.ts index 4a78319f..9ec6dc73 100644 --- a/src/services/player.test.ts +++ b/src/services/player.test.ts @@ -54,6 +54,17 @@ describe("getPlayerGlobalStats", () => { expect(parsed.success).toBe(true) } }) + + it("returns the correct shape for a private profile", async () => { + const data = await getPlayerGlobalStats("4611686018467346804").catch(console.error) + + const parsed = zPlayerProfileGlobalStats.safeParse(data) + if (!parsed.success) { + expect(parsed.error.errors).toEqual([]) + } else { + expect(parsed.success).toBe(true) + } + }) }) describe("getWorldFirstEntries", () => { diff --git a/src/services/player.ts b/src/services/player.ts index 14ca0134..74567c20 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -88,37 +88,37 @@ export const getPlayerGlobalStats = async (membershipId: bigint | string) => { postgres.queryRow( `SELECT JSONB_BUILD_OBJECT( - 'value', clears, - 'rank', clears_rank, - 'percentile', clears_percentile + 'value', COALESCE(lb.clears, player.clears, 0), + 'rank', lb.clears_rank, + 'percentile', lb.clears_percentile ) AS "clears", JSONB_BUILD_OBJECT( - 'value', fresh_clears, - 'rank', fresh_clears_rank, - 'percentile', fresh_clears_percentile + 'value', COALESCE(lb.fresh_clears, player.fresh_clears, 0), + 'rank', lb.fresh_clears_rank, + 'percentile', lb.fresh_clears_percentile ) AS "freshClears", JSONB_BUILD_OBJECT( - 'value', sherpas, - 'rank', sherpas_rank, - 'percentile', sherpas_percentile + 'value', COALESCE(lb.sherpas, player.sherpas, 0), + 'rank', lb.sherpas_rank, + 'percentile', lb.sherpas_percentile ) AS "sherpas", JSONB_BUILD_OBJECT( - 'value', total_time_played, - 'rank', total_time_played_rank, - 'percentile', total_time_played_percentile + 'value', COALESCE(lb.total_time_played, player.total_time_played_seconds, 0), + 'rank', lb.total_time_played_rank, + 'percentile', lb.total_time_played_percentile ) AS "totalTimePlayed", - CASE WHEN contest IS NOT NULL THEN JSONB_BUILD_OBJECT( - 'value', contest.score, - 'rank', contest.rank, - 'percentile', contest.percentile - ) ELSE NULL END AS "contest", - CASE WHEN speed IS NOT NULL THEN JSONB_BUILD_OBJECT( - 'value', speed, - 'rank', speed_rank, - 'percentile', speed_percentile - ) ELSE NULL END AS "sumOfBest" - FROM individual_global_leaderboard - LEFT JOIN world_first_player_rankings contest USING (membership_id) + JSONB_BUILD_OBJECT( + 'value', COALESCE(lb.speed, player.sum_of_best, 0), + 'rank', lb.speed_rank, + 'percentile', lb.speed_percentile + ) AS "sumOfBest", + JSONB_BUILD_OBJECT( + 'value', COALESCE(lb.wfr_score, player.wfr_score, 0), + 'rank', lb.wfr_score_rank, + 'percentile', lb.wfr_score_percentile + ) AS "contest" + FROM player + LEFT JOIN individual_global_leaderboard lb USING (membership_id) WHERE membership_id = $1::bigint`, { params: [membershipId]