From a3bfe5bddd9794b54b312d50abaa8b9d4ffb3a48 Mon Sep 17 00:00:00 2001 From: Taevas <67872932+TTTaevas@users.noreply.github.com> Date: Mon, 27 Nov 2023 21:31:36 +0100 Subject: [PATCH] Clean code, split `getUserScores` in two This means some changes to Score stuff for type safety sake A beatmap's source can no longer be 0 getURL's functions now support using a custom server --- lib/beatmap.ts | 77 +++++++++++++++++++++++------------------------- lib/index.ts | 80 +++++++++++++++++++++++++++----------------------- lib/match.ts | 50 +++++++++++++++---------------- lib/misc.ts | 28 +++++++++++------- lib/mods.ts | 2 +- lib/replay.ts | 2 +- lib/score.ts | 30 +++++++++++++++++-- lib/test.ts | 10 +++---- 8 files changed, 157 insertions(+), 122 deletions(-) diff --git a/lib/beatmap.ts b/lib/beatmap.ts index 2f0fdfa..a0ada88 100644 --- a/lib/beatmap.ts +++ b/lib/beatmap.ts @@ -2,54 +2,54 @@ * For the `approved` of a `Beatmap` (for example, `Categories[beatmap.approved]` would return "RANKED" if 1) https://osu.ppy.sh/wiki/en/Beatmap/Category */ export enum Categories { - GRAVEYARD = -2, - WIP = -1, - PENDING = 0, - RANKED = 1, - APPROVED = 2, - QUALIFIED = 3, + GRAVEYARD = -2, + WIP = -1, + PENDING = 0, + RANKED = 1, + APPROVED = 2, + QUALIFIED = 3, } /** * For the `genre_id` of a `Beatmap` (for example, `Genres[beatmap.genre_id]` would return "NOVELTY" if 7) */ export enum Genres { - ANY = 0, - UNSPECIFIED = 1, - "VIDEO GAME" = 2, - ANIME = 3, - ROCK = 4, - POP = 5, - OTHER = 6, - NOVELTY = 7, - "" = 8, - "HIP HOP" = 9, - ELECTRONIC = 10, - METAL = 11, - CLASSICAL = 12, - FOLK = 13, - JAZZ = 14, + ANY = 0, + UNSPECIFIED = 1, + "VIDEO GAME" = 2, + ANIME = 3, + ROCK = 4, + POP = 5, + OTHER = 6, + NOVELTY = 7, + "" = 8, + "HIP HOP" = 9, + ELECTRONIC = 10, + METAL = 11, + CLASSICAL = 12, + FOLK = 13, + JAZZ = 14, } /** * For the `language_id` of a `Beatmap` (for example, `Languages[beatmap.language_id]` would return "FRENCH" if 7) */ export enum Languages { - ANY = 0, - UNSPECIFIED = 1, - ENGLISH = 2, - JAPANESE = 3, - CHINESE = 4, - INSTRUMENTAL = 5, - KOREAN = 6, - FRENCH = 7, - GERMAN = 8, - SWEDISH = 9, - SPANISH = 10, - ITALIAN = 11, - RUSSIAN = 12, - POLISH = 13, - OTHER = 14, + ANY = 0, + UNSPECIFIED = 1, + ENGLISH = 2, + JAPANESE = 3, + CHINESE = 4, + INSTRUMENTAL = 5, + KOREAN = 6, + FRENCH = 7, + GERMAN = 8, + SWEDISH = 9, + SPANISH = 10, + ITALIAN = 11, + RUSSIAN = 12, + POLISH = 13, + OTHER = 14, } export interface Beatmap { @@ -120,10 +120,7 @@ export interface Beatmap { creator: string creator_id: number bpm: number - /** - * Is 0 if no source - */ - source: string | 0 + source: string tags: string genre_id: number language_id: number diff --git a/lib/index.ts b/lib/index.ts index 69a65f0..ccd143c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,13 +1,13 @@ import fetch, { FetchError } from "node-fetch" import { User } from "./user.js" -import { Score, ScoreWithBeatmapid } from "./score.js" +import { Score, ScoreWithBeatmapid, ScoreWithBeatmapidReplayavailablePp, ScoreWithReplayavailablePp } from "./score.js" import { Beatmap, Categories, Genres, Languages } from "./beatmap.js" import { Match, MultiplayerModes, WinConditions } from "./match.js" import { Mods, unsupported_mods } from "./mods.js" import { Replay } from "./replay.js" import { Gamemodes, getMods, getLength, getURL, adjustBeatmapStatsToMods } from "./misc.js" -export {Gamemodes, User, Score, ScoreWithBeatmapid, Mods, Replay} +export {Gamemodes, User, Score, ScoreWithBeatmapid, ScoreWithBeatmapidReplayavailablePp, ScoreWithReplayavailablePp, Mods, Replay} export {Beatmap, Categories, Genres, Languages} export {Match, MultiplayerModes, WinConditions} export {getMods, getLength, getURL, adjustBeatmapStatsToMods} @@ -103,7 +103,7 @@ export class API { * @param endpoint Basically the endpoint, what comes in the URL after `api/` * @param parameters The things to specify in the request, such as the beatmap_id when looking for a beatmap * @param number_try How many attempts there's been to get the data - * @returns A Promise with either the API's response or `false` upon failing + * @returns A Promise with the API's response */ private async request(endpoint: string, parameters: string, number_try: number = 1): Promise { const max_tries = 5 @@ -123,7 +123,7 @@ export class API { this.log(true, error.message) err = `${error.name} (${error.errno})` }) - + if (!response || !response.ok) { if (response) { err = response.statusText @@ -146,7 +146,7 @@ export class API { throw new APIError(err, this.server, endpoint, parameters) } - + this.log(false, response.statusText, response.status, {endpoint, parameters}) let json = await response.json() if (Array.isArray(json) && !json.length) { @@ -160,7 +160,7 @@ export class API { throw new APIError("Match not available.", this.server, endpoint, parameters) } } - return json + return correctType(json) } /** @@ -169,23 +169,32 @@ export class API { * @returns A Promise with a `User` found with the search */ async getUser(gamemode: Gamemodes, user: {user_id?: number, username?: string} | User): Promise { - let lookup = user.user_id !== undefined ? `u=${user.user_id}&type=id` : `u=${user.username}&type=string` - let response = await this.request("get_user", `m=${gamemode}&${lookup}`) as User[] - return correctType(response[0]) + const lookup = user.user_id !== undefined ? `u=${user.user_id}&type=id` : `u=${user.username}&type=string` + const response = await this.request("get_user", `m=${gamemode}&${lookup}`) as User[] + return response[0] } /** * @param limit The maximum number of Scores to get, **cannot exceed 100** * @param gamemode The `User`'s `Gamemode` - * @param user An Object with either a `user_id` or a `username` (ignores `username` if `user_id` is specified) - * @param plays The `User`'s top pp plays/`Scores` or the `User`'s plays/`Scores` within the last 24 hours - * @returns A Promise with an array of `Scores` set by the `User` in a specific `Gamemode` + * @param user An Object with either a `user_id` or a `username` (`user_id` is preferred) + * @returns A Promise with an array of `Scores` set by the `User` within the last 24 hours in a specific `Gamemode` + */ + async getUserBestScores(limit: number, gamemode: Gamemodes, user: {user_id?: number, username?: string} | User): + Promise { + const lookup = user.user_id !== undefined ? `u=${user.user_id}&type=id` : `u=${user.username}&type=string` + return await this.request("get_user_best", `${lookup}&m=${gamemode}&limit=${limit}`) + } + + /** + * @param limit The maximum number of Scores to get, **cannot exceed 100** + * @param gamemode The `User`'s `Gamemode` + * @param user An Object with either a `user_id` or a `username` (`user_id` is preferred) + * @returns A Promise with an array of `Scores` set by the `User` within the last 24 hours in a specific `Gamemode` */ - async getUserScores(limit: number, gamemode: Gamemodes, user: {user_id?: number, username?: string} | User, plays: "best" | "recent"): - Promise { - let lookup = user.user_id !== undefined ? `u=${user.user_id}&type=id` : `u=${user.username}&type=string` - let response = await this.request(`get_user_${plays}`, `${lookup}&m=${gamemode}&limit=${limit}`) as Score[] - return correctType(response) + async getUserRecentScores(limit: number, gamemode: Gamemodes, user: {user_id?: number, username?: string} | User): Promise { + const lookup = user.user_id !== undefined ? `u=${user.user_id}&type=id` : `u=${user.username}&type=string` + return await this.request("get_user_recent", `${lookup}&m=${gamemode}&limit=${limit}`) } /** @@ -198,10 +207,10 @@ export class API { async getBeatmap(beatmap: {beatmap_id: number} | Beatmap, mods: Mods = Mods.NONE, gamemode?: Gamemodes): Promise { if (getMods(mods).includes(Mods[Mods.NIGHTCORE]) && !getMods(mods).includes(Mods[Mods.DOUBLETIME])) {mods -= Mods.NIGHTCORE - Mods.DOUBLETIME} unsupported_mods.forEach((mod) => getMods(mods!).includes(Mods[mod]) ? mods! -= mod : mods! -= 0) - let g = gamemode !== undefined ? `&mode=${gamemode}&a=1` : "" + const g = gamemode !== undefined ? `&mode=${gamemode}&a=1` : "" - let response = await this.request("get_beatmaps", `b=${beatmap.beatmap_id}&mods=${mods}${g}`) as Beatmap[] - return adjustBeatmapStatsToMods(correctType(response[0]), mods) + const response = await this.request("get_beatmaps", `b=${beatmap.beatmap_id}&mods=${mods}${g}`) as Beatmap[] + return adjustBeatmapStatsToMods(response[0], mods) } /** @@ -222,10 +231,11 @@ export class API { ): Promise { if (getMods(mods).includes(Mods[Mods.NIGHTCORE]) && !getMods(mods).includes(Mods[Mods.DOUBLETIME])) {mods -= Mods.NIGHTCORE - Mods.DOUBLETIME} unsupported_mods.forEach((mod) => getMods(mods!).includes(Mods[mod]) ? mods! -= mod : mods! -= 0) + + const mode = gamemode.gamemode == "all" ? "" : `&m=${gamemode.gamemode}` + const convert = gamemode.allow_converts ? "a=1" : "a=0" let lookup = `mods=${mods}` - let mode = gamemode.gamemode == "all" ? "" : `&m=${gamemode.gamemode}` - let convert = gamemode.allow_converts ? "a=1" : "a=0" - + if (beatmap) { if (beatmap.beatmapset_id !== undefined) { lookup += `&s=${beatmap.beatmapset_id}` @@ -249,9 +259,8 @@ export class API { lookup += x.substring(0, x.indexOf("Z") - 4) } - let response = await this.request("get_beatmaps", `limit=${limit}${mode}&${convert}${lookup}`) as Beatmap[] - let beatmaps: Beatmap[] = response.map((b: Beatmap) => adjustBeatmapStatsToMods(correctType(b), mods || Mods.NONE)) - return beatmaps + const response = await this.request("get_beatmaps", `limit=${limit}${mode}&${convert}${lookup}`) as Beatmap[] + return response.map((b: Beatmap) => adjustBeatmapStatsToMods(correctType(b), mods || Mods.NONE)) } /** @@ -263,10 +272,9 @@ export class API { * @returns A Promise with an array of `Scores` set on a beatmap */ async getBeatmapScores(limit: number, gamemode: Gamemodes, beatmap: {beatmap_id: number} | Beatmap, - user?: {user_id?: number, username?: string} | User, mods?: Mods): Promise { - let user_lookup = user ? user.user_id !== undefined ? `u=${user.user_id}&type=id` : `u=${user.username}&type=string` : "" - let response = await this.request("get_scores", `b=${beatmap.beatmap_id}&m=${gamemode}${mods !== undefined ? "&mods="+mods : ""}${user_lookup}&limit=${limit}`) - return correctType(response) as Score[] + user?: {user_id?: number, username?: string} | User, mods?: Mods): Promise { + const user_lookup = user ? user.user_id !== undefined ? `u=${user.user_id}&type=id` : `u=${user.username}&type=string` : "" + return await this.request("get_scores", `b=${beatmap.beatmap_id}&m=${gamemode}${mods !== undefined ? "&mods="+mods : ""}${user_lookup}&limit=${limit}`) } /** @@ -276,10 +284,9 @@ export class API { * see https://docs.ripple.moe/docs/api/peppy */ async getMatch(id: number): Promise { - let response = await this.request("get_match", `mp=${id}`) - return correctType(response) as Match + return await this.request("get_match", `mp=${id}`) } - + /** * Specify the gamemode the score was set in, then say if you know the id of the `Score` OR if you know the score's `User`, `Beatmap`, and `Mods` * @param gamemode A number representing the `Gamemode` the `Score` was set in @@ -290,9 +297,9 @@ export class API { */ async getReplay(gamemode: Gamemodes, replay: {score?: {score_id: number} | Score, search?: {user: {user_id?: number, username?: string} | User, beatmap: {beatmap_id: number} | Beatmap, mods: Mods}}): Promise { + const [score, search] = [replay.score, replay.search] let lookup = "" - let [score, search] = [replay.score, replay.search] - + if (score !== undefined) { lookup = `s=${score.score_id}` } else if (search !== undefined) { @@ -305,7 +312,6 @@ export class API { lookup += `&mods=${search.mods}` } - let response = await this.request("get_replay", `${lookup}&m=${gamemode}`) - return correctType(response) as Replay + return await this.request("get_replay", `${lookup}&m=${gamemode}`) } } diff --git a/lib/match.ts b/lib/match.ts index 9a34882..6bce2ed 100644 --- a/lib/match.ts +++ b/lib/match.ts @@ -2,20 +2,20 @@ * https://osu.ppy.sh/wiki/en/Client/Interface/Multiplayer#team-mode-gameplay */ export enum MultiplayerModes { - "HEAD TO HEAD" = 0, - "TAG CO-OP" = 1, - "TEAM VS" = 2, - "TAG TEAM VS" = 3, + "HEAD TO HEAD" = 0, + "TAG CO-OP" = 1, + "TEAM VS" = 2, + "TAG TEAM VS" = 3, } /** * https://osu.ppy.sh/wiki/en/Client/Interface/Multiplayer#win-condition */ export enum WinConditions { - SCORE = 0, - ACCURACY = 1, - COMBO = 2, - "SCORE V2" = 3, + SCORE = 0, + ACCURACY = 1, + COMBO = 2, + "SCORE V2" = 3, } export interface Match { @@ -23,9 +23,9 @@ export interface Match { * Has the info about the match that is not related to what's been played */ match: { - match_id: number, - name: string, - start_time: Date, + match_id: number + name: string + start_time: Date /** * If the match is not disbanded, null */ @@ -45,32 +45,32 @@ export interface Match { */ mods: number scores: { - slot: number, + slot: number /** * 0 if no team, 1 if blue, 2 if red */ - team: number, - user_id: number, - score: number, - maxcombo: number, + team: number + user_id: number + score: number + maxcombo: number /** * Is always 0, "is not used" according to documentation */ - rank: number, - count50: number, - count100: number, - count300: number, - countmiss: number, - countgeki: number, - countkatu: number, + rank: number + count50: number + count100: number + count300: number + countmiss: number + countgeki: number + countkatu: number /** * Documentation says "If full combo", but should be "If SS/100% accuracy" */ - perfect: boolean, + perfect: boolean /** * If the player is alive at the end of the map */ - pass: boolean, + pass: boolean /** * Is null if no freemod */ diff --git a/lib/misc.ts b/lib/misc.ts index e5b5baa..118b865 100644 --- a/lib/misc.ts +++ b/lib/misc.ts @@ -11,19 +11,19 @@ export enum Gamemodes { /** * https://osu.ppy.sh/wiki/en/Game_mode/osu%21 */ - OSU = 0, + OSU = 0, /** * https://osu.ppy.sh/wiki/en/Game_mode/osu%21taiko */ - TAIKO = 1, + TAIKO = 1, /** * https://osu.ppy.sh/wiki/en/Game_mode/osu%21catch */ - CTB = 2, + CTB = 2, /** * https://osu.ppy.sh/wiki/en/Game_mode/osu%21mania */ - MANIA = 3, + MANIA = 3, } @@ -34,7 +34,7 @@ export enum Gamemodes { * @returns An Array of Strings, each string representing a mod */ export function getMods(value: Mods): string[] { - let arr: string[] = [] + const arr: string[] = [] for (let bit = 1; bit != 0; bit <<= 1) { if ((value & bit) != 0 && bit in Mods) {arr.push(Mods[bit])} } @@ -67,20 +67,28 @@ export function getLength(seconds: number): string { export const getURL = { /** * @param beatmap An Object with the `beatmapset_id` of the Beatmap + * @param server (defaults to "https://assets.ppy.sh") The server hosting the file * @returns The URL of a 900x250 JPEG image */ - beatmapCoverImage: (beatmap: {beatmapset_id: number} | Beatmap): string => `https://assets.ppy.sh/beatmaps/${beatmap.beatmapset_id}/covers/cover.jpg`, + beatmapCoverImage: (beatmap: {beatmapset_id: number} | Beatmap, server: string = "https://assets.ppy.sh"): string => { + return `${server}/beatmaps/${beatmap.beatmapset_id}/covers/cover.jpg` + }, /** * @param beatmap An Object with the `beatmapset_id` of the Beatmap + * @param server (defaults to "https://b.ppy.sh") The server hosting the file * @returns The URL of a 160x120 JPEG image */ - beatmapCoverThumbnail: (beatmap: {beatmapset_id: number} | Beatmap): string => `https://b.ppy.sh/thumb/${beatmap.beatmapset_id}l.jpg`, - + beatmapCoverThumbnail: (beatmap: {beatmapset_id: number} | Beatmap, server: string = "https://b.ppy.sh"): string => { + return `${server}/thumb/${beatmap.beatmapset_id}l.jpg` + }, /** * @param user An Object with the `user_id` of the User + * @param server (defaults to "https://s.ppy.sh") The server hosting the file * @returns The URL of a JPEG image of variable proportions (max and ideally 256x256) */ - userProfilePicture: (user: {user_id: number} | User): string => `https://s.ppy.sh/a/${user.user_id}`, + userProfilePicture: (user: {user_id: number} | User, server: string = "https://s.ppy.sh"): string => { + return `${server}/a/${user.user_id}` + }, /** * The URLs in that Object do not use HTTPS, and instead (try to) open the osu! client in order to do something @@ -130,7 +138,7 @@ export const getURL = { * @param mods The Mods to which the Beatmap will be adapted * @returns The Beatmap, but adjusted to the Mods */ -export const adjustBeatmapStatsToMods: (beatmap: Beatmap, mods: Mods) => Beatmap = (beatmap: Beatmap, mods: Mods) => { +export const adjustBeatmapStatsToMods = (beatmap: Beatmap, mods: Mods): Beatmap => { beatmap = Object.assign({}, beatmap) // Do not change the original Beatmap outside this function const arr = getMods(mods) const convertARtoMS = (ar: number) => { diff --git a/lib/mods.ts b/lib/mods.ts index e62c17e..61fcd13 100644 --- a/lib/mods.ts +++ b/lib/mods.ts @@ -50,7 +50,7 @@ export enum Mods { * API returns the SR (and pp stuff) of a Beatmap as 0/null if any of those mods are included */ export const unsupported_mods = [ - Mods.NOFAIL, Mods.HIDDEN, Mods.SPUNOUT, Mods.FADEIN, Mods.NIGHTCORE, // note that Score.enabled_mods, when Nightcore, also has DoubleTime + Mods.NOFAIL, Mods.HIDDEN, Mods.SPUNOUT, Mods.FADEIN, Mods.NIGHTCORE, Mods.SUDDENDEATH, Mods.PERFECT, Mods.RELAX, Mods.AUTOPLAY, Mods.AUTOPILOT, Mods.CINEMA, Mods.RANDOM, Mods.TARGET, Mods.SCOREV2, Mods.MIRROR, diff --git a/lib/replay.ts b/lib/replay.ts index d1168ae..bf3ff69 100644 --- a/lib/replay.ts +++ b/lib/replay.ts @@ -5,7 +5,7 @@ export interface Replay { /** * Encoded LZMA stream */ - content: string, + content: string /** * The encoding `content` uses, should always be "base64" */ diff --git a/lib/score.ts b/lib/score.ts index 0b794ff..3260257 100644 --- a/lib/score.ts +++ b/lib/score.ts @@ -1,8 +1,12 @@ export interface Score { /** - * If the score is a fail (is not completed, has a "F" rank/grade) then this is null + * The id of the score! + * @remarks If the score is a fail (is not completed, has a "F" rank/grade) then this is null */ score_id: number | null + /** + * The score itself, for example 923,357 (without the comma ",") + */ score: number maxcombo: number count50: number @@ -23,6 +27,7 @@ export interface Score { perfect: boolean /** * Bitwise flag, feel free to use `getMods` to see the mods in a more readable way! + * @remarks If it has Nightcore, it also has DoubleTime */ enabled_mods: number user_id: number @@ -34,10 +39,29 @@ export interface Score { * Also known as the Grade https://osu.ppy.sh/wiki/en/Gameplay/Grade, it may be "F" if the player failed */ rank: string - pp?: number - replay_available?: boolean } export interface ScoreWithBeatmapid extends Score { beatmap_id: number } + +export interface ScoreWithReplayavailablePp extends Score { + /** + * @remarks It can't be null, because ScoreWithReplayavailablePp is only available for scores that haven't failed + */ + score_id: number + /** + * How much pp the score play is worth! + * @remarks Null if beatmap is loved (for example) + */ + pp: number | null + replay_available: boolean +} + +export interface ScoreWithBeatmapidReplayavailablePp extends ScoreWithBeatmapid, ScoreWithReplayavailablePp { + score_id: number + /** + * @remarks It can't be null, because ScoreWithBeatmapidReplayavailablePp is only available for scores that are worth something + */ + pp: number +} \ No newline at end of file diff --git a/lib/test.ts b/lib/test.ts index e8a907d..ab9fbbb 100644 --- a/lib/test.ts +++ b/lib/test.ts @@ -141,12 +141,12 @@ const testRequests = async(): Promise => { "getBeatmapScores: ", api.getBeatmapScores(5, osu.Gamemodes.OSU, {beatmap_id: 129891}, {user_id: 124493}, osu.Mods.HIDDEN + osu.Mods.HARDROCK)) if (!isOk(beatmap_scores, !beatmap_scores || (beatmap_scores[0].score >= 132408001 && validate(beatmap_scores, "Score", score_gen)))) okay = false - const user_best_scores = await | false>>attempt( - "getUserScores (best):", api.getUserScores(10, osu.Gamemodes.OSU, {user_id: 2}, "best") + const user_best_scores = await | false>>attempt( + "getUserBestScores: ", api.getUserBestScores(90, osu.Gamemodes.OSU, {user_id: 2}) ) - if (!isOk(user_best_scores, !user_best_scores || (user_best_scores.length <= 10 && validate(user_best_scores, "Score", score_gen)))) okay = false - const user_recent_scores = await | false>>attempt( - "getUserScores (recent):", api.getUserScores(10, osu.Gamemodes.CTB, {user_id: 8172283}, "recent") + if (!isOk(user_best_scores, !user_best_scores || (user_best_scores.length <= 90 && validate(user_best_scores, "Score", score_gen)))) okay = false + const user_recent_scores = await | false>>attempt( + "getUserRecentScores: ", api.getUserRecentScores(10, osu.Gamemodes.CTB, {user_id: 8172283}) ) if (!isOk(user_recent_scores, !user_recent_scores || (user_recent_scores.length <= 10 && validate(user_recent_scores, "Score", score_gen)))) okay = false