From da0fd0d96cd761cedd136cb271eb3228294c0794 Mon Sep 17 00:00:00 2001 From: Joshua Raphael Date: Fri, 9 Jan 2026 22:01:20 -0700 Subject: [PATCH 1/2] feat: add getGameProgression() --- README.md | 1 + src/game/getGameProgression.test.ts | 114 ++++++++++++++++++ src/game/getGameProgression.ts | 112 +++++++++++++++++ src/game/index.ts | 1 + src/game/models/game-progression.model.ts | 32 +++++ .../get-game-progression-response.model.ts | 32 +++++ src/game/models/index.ts | 2 + 7 files changed, 294 insertions(+) create mode 100644 src/game/getGameProgression.test.ts create mode 100644 src/game/getGameProgression.ts create mode 100644 src/game/models/game-progression.model.ts create mode 100644 src/game/models/get-game-progression-response.model.ts diff --git a/README.md b/README.md index 791186e..2be82dd 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ Click the function names to open their complete docs on the docs site. - [`getGame()`](https://api-docs.retroachievements.org/v1/get-game.html) - Get basic metadata about a game. - [`getGameExtended()`](https://api-docs.retroachievements.org/v1/get-game-extended.html) - Get extended metadata about a game. - [`getGameHashes()`](https://api-docs.retroachievements.org/v1/get-game-hashes.html) - Get a list of hashes linked to a game. +- [`getGameProgression()`](https://api-docs.retroachievements.org/v1/get-game-progression.html) - Get information about the average time to unlock achievements in a game. - [`getAchievementCount()`](https://api-docs.retroachievements.org/v1/get-achievement-count.html) - Get the list of achievement IDs for a game. - [`getAchievementDistribution()`](https://api-docs.retroachievements.org/v1/get-achievement-distribution.html) - Get how many players have unlocked how many achievements for a game. - [`getGameRankAndScore()`](https://api-docs.retroachievements.org/v1/get-game-rank-and-score.html) - Get a list of either the latest masters or highest hardcore points earners for a game. diff --git a/src/game/getGameProgression.test.ts b/src/game/getGameProgression.test.ts new file mode 100644 index 0000000..22ec85a --- /dev/null +++ b/src/game/getGameProgression.test.ts @@ -0,0 +1,114 @@ +/* eslint-disable sonarjs/no-duplicate-string */ + +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; + +import { apiBaseUrl } from "../utils/internal"; +import { buildAuthorization } from "../utils/public"; +import { getGameProgression } from "./getGameProgression"; +import type { GetGameProgressionResponse } from "./models"; + +const server = setupServer(); + +describe("Function: getGameProgression", () => { + // MSW Setup + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it("is defined #sanity", () => { + // ASSERT + expect(getGameProgression).toBeDefined(); + }); + + it("given a game ID, retrieves information about the average time to unlock achievements in a game", async () => { + // ARRANGE + const authorization = buildAuthorization({ + username: "mockUserName", + webApiKey: "mockWebApiKey", + }); + + const mockResponse: GetGameProgressionResponse = { + ID: 228, + Title: "Super Mario World", + ConsoleID: 3, + ConsoleName: "SNES/Super Famicom", + ImageIcon: "/Images/112443.png", + NumDistinctPlayers: 79_281, + TimesUsedInBeatMedian: 4493, + TimesUsedInHardcoreBeatMedian: 8249, + MedianTimeToBeat: 17_878, + MedianTimeToBeatHardcore: 19_224, + TimesUsedInCompletionMedian: 155, + TimesUsedInMasteryMedian: 1091, + MedianTimeToComplete: 67_017, + MedianTimeToMaster: 79_744, + NumAchievements: 89, + Achievements: [ + { + ID: 342, + Title: "Giddy Up!", + Description: "Catch a ride with a friend", + Points: 1, + TrueRatio: 1, + Type: null, + BadgeName: "46580", + NumAwarded: 75_168, + NumAwardedHardcore: 37_024, + TimesUsedInUnlockMedian: 63, + TimesUsedInHardcoreUnlockMedian: 69, + MedianTimeToUnlock: 274, + MedianTimeToUnlockHardcore: 323, + }, + ], + }; + + server.use( + http.get(`${apiBaseUrl}/API_GetGameProgression.php`, () => + HttpResponse.json(mockResponse) + ) + ); + + // ACT + const response = await getGameProgression(authorization, { + gameId: 104_370, + hardcore: true, + }); + + // ASSERT + expect(response).toEqual({ + id: 228, + title: "Super Mario World", + consoleId: 3, + consoleName: "SNES/Super Famicom", + imageIcon: "/Images/112443.png", + numDistinctPlayers: 79_281, + timesUsedInBeatMedian: 4493, + timesUsedInHardcoreBeatMedian: 8249, + medianTimeToBeat: 17_878, + medianTimeToBeatHardcore: 19_224, + timesUsedInCompletionMedian: 155, + timesUsedInMasteryMedian: 1091, + medianTimeToComplete: 67_017, + medianTimeToMaster: 79_744, + numAchievements: 89, + achievements: [ + { + id: 342, + title: "Giddy Up!", + description: "Catch a ride with a friend", + points: 1, + trueRatio: 1, + type: null, + badgeName: "46580", + numAwarded: 75_168, + numAwardedHardcore: 37_024, + timesUsedInUnlockMedian: 63, + timesUsedInHardcoreUnlockMedian: 69, + medianTimeToUnlock: 274, + medianTimeToUnlockHardcore: 323, + }, + ], + }); + }); +}); diff --git a/src/game/getGameProgression.ts b/src/game/getGameProgression.ts new file mode 100644 index 0000000..15b050a --- /dev/null +++ b/src/game/getGameProgression.ts @@ -0,0 +1,112 @@ +import type { ID } from "../utils/internal"; +import { + apiBaseUrl, + buildRequestUrl, + call, + serializeProperties, +} from "../utils/internal"; +import type { AuthObject } from "../utils/public"; +import type { GameProgression, GetGameProgressionResponse } from "./models"; + +/** + * A call to this function will retrieve information about the average time to unlock achievements in a game. + * + * @param authorization An object containing your username and webApiKey. + * This can be constructed with `buildAuthorization()`. + * + * @param payload.gameId The unique game ID. If you are unsure, open the + * game's page on the RetroAchievements.org website. For example, Dragster's + * URL is https://retroachievements.org/game/14402. We can see from the + * URL that the game ID is "14402". + * + * @param payload.hardcore Optional. By default, set to false, with both + * softcore and hardcore tallies returned in the response. If this option + * is set to true, only hardcore unlocks will be included in the totals. + * + * @example + * ``` + * const game = await getGameProgression( + * authorization, + * { gameId: 14402, hardcore: true } + * ); + * ``` + * + * @returns An object containing information about the average time to unlock achievements in a game. + * ```json + * { + * "id": 228, + * "title": "Super Mario World", + * "consoleId": 3, + * "consoleName": "SNES/Super Famicom", + * "imageIcon": "/Images/112443.png", + * "numDistinctPlayers": 79281, + * "timesUsedInBeatMedian": 4493, + * "timesUsedInHardcoreBeatMedian": 8249, + * "medianTimeToBeat": 17878, + * "medianTimeToBeatHardcore": 19224, + * "timesUsedInCompletionMedian": 155, + * "timesUsedInMasteryMedian": 1091, + * "medianTimeToComplete": 67017, + * "medianTimeToMaster": 79744, + * "numAchievements": 89, + * "achievements": [ + * { + * "id": 342, + * "title": "Giddy Up!", + * "description": "Catch a ride with a friend", + * "points": 1, + * "trueRatio": 1, + * "type": null, + * "badgeName": "46580", + * "numAwarded": 75168, + * "numAwardedHardcore": 37024, + * "timesUsedInUnlockMedian": 63, + * "timesUsedInHardcoreUnlockMedian": 69, + * "medianTimeToUnlock": 274, + * "medianTimeToUnlockHardcore": 323 + * }, + * { + * "id": 341, + * "title": "Unleash The Dragon", + * "description": "Collect 5 Dragon Coins in a level", + * "points": 2, + * "trueRatio": 2, + * "type": null, + * "badgeName": "46591", + * "numAwarded": 66647, + * "numAwardedHardcore": 34051, + * "timesUsedInUnlockMedian": 66, + * "timesUsedInHardcoreUnlockMedian": 70, + * "medianTimeToUnlock": 290, + * "medianTimeToUnlockHardcore": 333 + * } + * ] + * } + * ``` + */ +export const getGameProgression = async ( + authorization: AuthObject, + payload: { + gameId: ID; + hardcore?: boolean; + } +): Promise => { + const { gameId, hardcore } = payload; + + const queryParams: Record = { i: gameId }; + + if (hardcore !== undefined) { + queryParams["h"] = hardcore === true ? 1 : 0; + } + + const url = buildRequestUrl( + apiBaseUrl, + "/API_GetGameProgression.php", + authorization, + queryParams + ); + + const rawResponse = await call({ url }); + + return serializeProperties(rawResponse); +}; diff --git a/src/game/index.ts b/src/game/index.ts index aa6246e..55ace75 100644 --- a/src/game/index.ts +++ b/src/game/index.ts @@ -3,6 +3,7 @@ export * from "./getAchievementDistribution"; export * from "./getGame"; export * from "./getGameExtended"; export * from "./getGameHashes"; +export * from "./getGameProgression"; export * from "./getGameRankAndScore"; export * from "./getGameRating"; export * from "./models"; diff --git a/src/game/models/game-progression.model.ts b/src/game/models/game-progression.model.ts new file mode 100644 index 0000000..92e763c --- /dev/null +++ b/src/game/models/game-progression.model.ts @@ -0,0 +1,32 @@ +export interface GameProgression { + id: number; + title: string; + consoleId: number; + consoleName: string; + imageIcon: string; + numDistinctPlayers: number; + timesUsedInBeatMedian: number; + timesUsedInHardcoreBeatMedian: number; + medianTimeToBeat: number | null; + medianTimeToBeatHardcore: number | null; + timesUsedInCompletionMedian: number; + timesUsedInMasteryMedian: number; + medianTimeToComplete: number | null; + medianTimeToMaster: number | null; + numAchievements: number; + achievements: Array<{ + id: number; + title: string; + description: string; + points: number; + trueRatio: number; + type: string | null; + badgeName: string; + numAwarded: number; + numAwardedHardcore: number; + timesUsedInUnlockMedian: number; + timesUsedInHardcoreUnlockMedian: number; + medianTimeToUnlock: number; + medianTimeToUnlockHardcore: number; + }>; +} diff --git a/src/game/models/get-game-progression-response.model.ts b/src/game/models/get-game-progression-response.model.ts new file mode 100644 index 0000000..5de7687 --- /dev/null +++ b/src/game/models/get-game-progression-response.model.ts @@ -0,0 +1,32 @@ +export interface GetGameProgressionResponse { + ID: number; + Title: string; + ConsoleID: number; + ConsoleName: string; + ImageIcon: string; + NumDistinctPlayers: number; + TimesUsedInBeatMedian: number; + TimesUsedInHardcoreBeatMedian: number; + MedianTimeToBeat: number | null; + MedianTimeToBeatHardcore: number | null; + TimesUsedInCompletionMedian: number; + TimesUsedInMasteryMedian: number; + MedianTimeToComplete: number | null; + MedianTimeToMaster: number | null; + NumAchievements: number; + Achievements: Array<{ + ID: number; + Title: string; + Description: string; + Points: number; + TrueRatio: number; + Type: string | null; + BadgeName: string; + NumAwarded: number; + NumAwardedHardcore: number; + TimesUsedInUnlockMedian: number; + TimesUsedInHardcoreUnlockMedian: number; + MedianTimeToUnlock: number; + MedianTimeToUnlockHardcore: number; + }>; +} diff --git a/src/game/models/index.ts b/src/game/models/index.ts index 3649378..e4a5a0c 100644 --- a/src/game/models/index.ts +++ b/src/game/models/index.ts @@ -5,12 +5,14 @@ export * from "./game-extended.model"; export * from "./game-extended-achievement-entity.model"; export * from "./game-extended-claim-entity.model"; export * from "./game-hashes.model"; +export * from "./game-progression.model"; export * from "./game-rank-and-score-entity.model"; export * from "./game-rating.model"; export * from "./get-achievement-count-response.model"; export * from "./get-achievement-distribution-response.model"; export * from "./get-game-extended-response.model"; export * from "./get-game-hashes-response.model"; +export * from "./get-game-progression-response.model"; export * from "./get-game-rank-and-score-response.model"; export * from "./get-game-rating-response.model"; export * from "./get-game-response.model"; From 24fb52677c739695b8831da7b9f8460dea24bd88 Mon Sep 17 00:00:00 2001 From: Joshua Raphael Date: Sat, 10 Jan 2026 05:50:01 -0700 Subject: [PATCH 2/2] fix: update getGameProgression js doc --- src/game/getGameProgression.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/game/getGameProgression.ts b/src/game/getGameProgression.ts index 15b050a..46bdb43 100644 --- a/src/game/getGameProgression.ts +++ b/src/game/getGameProgression.ts @@ -19,9 +19,9 @@ import type { GameProgression, GetGameProgressionResponse } from "./models"; * URL is https://retroachievements.org/game/14402. We can see from the * URL that the game ID is "14402". * - * @param payload.hardcore Optional. By default, set to false, with both - * softcore and hardcore tallies returned in the response. If this option - * is set to true, only hardcore unlocks will be included in the totals. + * @param payload.hardcore Optional. If set to true, the player sampling + * for median calculations will prefer players based on their hardcore + * unlock count rather than their total unlock count. * * @example * ```