diff --git a/java/beatoraja-ir/build.gradle.kts b/java/beatoraja-ir/build.gradle.kts index 7a8750bde..d6dfa6460 100644 --- a/java/beatoraja-ir/build.gradle.kts +++ b/java/beatoraja-ir/build.gradle.kts @@ -9,7 +9,7 @@ java { } group = "xyz.zkldi.bokutachiIR" -version = "3.1.2" +version = "4.0.0" repositories { mavenCentral() diff --git a/java/beatoraja-ir/src/main/java/bms/player/beatoraja/ir/TachiIR.java b/java/beatoraja-ir/src/main/java/bms/player/beatoraja/ir/TachiIR.java index 8b9004605..236757f37 100755 --- a/java/beatoraja-ir/src/main/java/bms/player/beatoraja/ir/TachiIR.java +++ b/java/beatoraja-ir/src/main/java/bms/player/beatoraja/ir/TachiIR.java @@ -1,6 +1,8 @@ package bms.player.beatoraja.ir; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Properties; @@ -33,6 +35,8 @@ public class TachiIR implements IRConnection { public static final String HOME; public static final String VERSION; private static final String BASE_URL; + private static final OkHttpClient HTTP_CLIENT = new OkHttpClient(); + private static final ObjectMapper MAPPER = new ObjectMapper(); static { var properties = new Properties(); @@ -82,9 +86,9 @@ public class TachiIR implements IRConnection { int statusCode; TachiResponse(JsonNode actualObj, int code) { - success = actualObj.get("success").asBoolean(); - description = actualObj.get("description").asText(); - body = actualObj.get("body"); + success = actualObj.path("success").asBoolean(false); + description = actualObj.path("description").asText("Unknown response."); + body = actualObj.path("body"); statusCode = code; } } @@ -103,16 +107,16 @@ public class TachiIR implements IRConnection { * Makes a GET request to BASE_URL + url. */ TachiResponse GETRequest(String url) throws Exception { - OkHttpClient client = new OkHttpClient(); - Request request = new Request.Builder().url(BASE_URL + url).header("User-Agent", "OKHTTP") .header("X-TachiIR-Version", VERSION).addHeader("Authorization", "Bearer " + apiToken) .addHeader("Accept", "application/json").build(); - try (Response response = client.newCall(request).execute()) { + try (Response response = HTTP_CLIENT.newCall(request).execute()) { int code = response.code(); - ObjectMapper mapper = new ObjectMapper(); - JsonNode actualObj = mapper.readTree(response.body().string()); + String responseBody = response.body() == null ? "" : response.body().string(); + JsonNode actualObj = responseBody.isEmpty() + ? MAPPER.createObjectNode().put("success", false).put("description", "Empty response.") + : MAPPER.readTree(responseBody); return new TachiResponse(actualObj, code); } @@ -123,7 +127,6 @@ public class TachiIR implements IRConnection { * body. */ TachiResponse POSTRequest(String url, String JSON) throws Exception { - OkHttpClient client = new OkHttpClient(); // charset=utf-8 is redundant, but is here just incase. RequestBody body = RequestBody.create(MediaType.get("application/json; charset=utf-8"), JSON); @@ -131,10 +134,12 @@ public class TachiIR implements IRConnection { .header("X-TachiIR-Version", VERSION).addHeader("Accept", "application/json") .addHeader("Authorization", "Bearer " + apiToken).post(body).build(); - try (Response response = client.newCall(request).execute()) { + try (Response response = HTTP_CLIENT.newCall(request).execute()) { int code = response.code(); - ObjectMapper mapper = new ObjectMapper(); - JsonNode actualObj = mapper.readTree(response.body().string()); + String responseBody = response.body() == null ? "" : response.body().string(); + JsonNode actualObj = responseBody.isEmpty() + ? MAPPER.createObjectNode().put("success", false).put("description", "Empty response.") + : MAPPER.readTree(responseBody); return new TachiResponse(actualObj, code); } @@ -178,7 +183,7 @@ public class TachiIR implements IRConnection { /** * Since we extend/implement a class with the IR, we're not allowed to use the * "throws exception" function signature modifier. - * + * * This is the only way to throw errors, and is generally a horrific idea. Ah * well. */ @@ -188,14 +193,15 @@ public class TachiIR implements IRConnection { } public IRResponse register(IRAccount account) { - return null; + ResponseCreator rc = new ResponseCreator(); + return rc.create(false, "Registration is handled on the Tachi website.", null); } private String username; /** * Basically does nothing. Performs some init and status checks for the IR. - * + * * Authentication is already handled with API keys, and users are expected to * place their relevant API key inside `password`. */ @@ -248,6 +254,7 @@ public class TachiIR implements IRConnection { try { TachiResponse resp = GETRequest("/api/v1/status?echo=lr2oraja-ir"); + JsonNode userBody = MAPPER.createObjectNode(); if (resp.success) { log("Connected to " + BASE_URL + ".", Importance.DEBUG); @@ -258,6 +265,7 @@ public class TachiIR implements IRConnection { log("Sending request to /api/v1/users/" + username, Importance.INFO); if (userResp.success) { + userBody = userResp.body; log("Authenticated as " + userResp.body.get("username").asText() + ".", Importance.INFO); } else { log("Failed to find out who you are. Can't login!", Importance.ERROR); @@ -269,7 +277,12 @@ public class TachiIR implements IRConnection { _throw(); } - return rc.create(resp.success, resp.description, null); + IRPlayerData playerData = new IRPlayerData( + userBody.path("id").asText(username), + userBody.path("username").asText(username), + ""); + + return rc.create(resp.success, resp.description, playerData); } catch (Exception e) { System.out.println(e.toString()); return rc.create(false, "Internal Exception", null); @@ -290,7 +303,7 @@ public class TachiIR implements IRConnection { /** * Submits a score to the IR. This POSTs data out to submit-score. - * + * * @warn This basically just serialises IRScoreData. If a beatoraja update * causes this to collapse in on itself, that sucks. */ @@ -320,6 +333,58 @@ public class TachiIR implements IRConnection { } } + private String urlEncode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private IRScoreData parseScoreData(JsonNode objNode) { + ScoreData scoreData = new ScoreData(); + + // Yeah, this is just java. + scoreData.setDate(objNode.path("date").asLong()); + scoreData.setPlayer(objNode.path("player").asText()); + scoreData.setSha256(objNode.path("sha256").asText()); + scoreData.setGauge(objNode.path("gauge").asInt()); + scoreData.setEpg(objNode.path("epg").asInt()); + scoreData.setLpg(objNode.path("lpg").asInt()); + scoreData.setEgr(objNode.path("egr").asInt()); + scoreData.setLgr(objNode.path("lgr").asInt()); + scoreData.setEgd(objNode.path("egd").asInt()); + scoreData.setLgd(objNode.path("lgd").asInt()); + scoreData.setEbd(objNode.path("ebd").asInt()); + scoreData.setLbd(objNode.path("lbd").asInt()); + scoreData.setEpr(objNode.path("epr").asInt()); + scoreData.setLpr(objNode.path("lpr").asInt()); + scoreData.setEms(objNode.path("ems").asInt()); + scoreData.setLms(objNode.path("lms").asInt()); + scoreData.setNotes(objNode.path("notes").asInt()); + scoreData.setPassnotes(objNode.path("passnotes").asInt()); + scoreData.setClear(objNode.path("clear").asInt()); + scoreData.setPlaycount(objNode.path("playcount").asInt()); + scoreData.setRandom(objNode.path("random").asInt()); + scoreData.setMinbp(objNode.path("minbp").asInt()); + scoreData.setCombo(objNode.path("maxcombo").asInt()); + scoreData.setMode(0); + + return new IRScoreData(scoreData); + } + + private IRScoreData[] parseScores(JsonNode body) { + ArrayList irScoreDatum = new ArrayList(); + + for (final JsonNode objNode : body) { + irScoreDatum.add(parseScoreData(objNode)); + } + + // weird java oddities: [0] instantiates a list faster than prealloc + IRScoreData[] irScoreArr = irScoreDatum.toArray(new IRScoreData[0]); + + // Beatoraja expects these to be sorted. + Arrays.sort(irScoreArr, (a, b) -> b.getExscore() - a.getExscore()); + + return irScoreArr; + } + class CourseData { public IRCourseData course; public IRScoreData score; @@ -359,7 +424,7 @@ public class TachiIR implements IRConnection { /** * Retrieves other scores on this chart. - * + * * @warn Beatoraja MANDATES that every single record on this chart is returned. * If Tachi ever blows up to LR2IR scale, this function will obliterate * both the IR and itself, and aggressive caching will have to be invoked. @@ -369,67 +434,55 @@ public class TachiIR implements IRConnection { ResponseCreator rc = new ResponseCreator(); try { - TachiResponse resp = GETRequest("/ir/beatoraja/charts/" + model.sha256 + "/scores"); + TachiResponse resp; + + if (model != null) { + resp = GETRequest("/ir/beatoraja/charts/" + urlEncode(model.sha256) + "/scores"); + } else if (irpd != null) { + resp = GETRequest("/ir/beatoraja/players/" + urlEncode(irpd.id) + "/scores"); + } else { + return rc.create(false, "Expected either a player or chart.", new IRScoreData[0]); + } if (!resp.success) { - return rc.create(false, "No chart data.", null); + return rc.create(false, resp.description, new IRScoreData[0]); } - ArrayList irScoreDatum = new ArrayList(); - - for (final JsonNode objNode : resp.body) { - ScoreData scoreData = new ScoreData(); - - // Yeah, this is just java. - scoreData.setDate(Long.valueOf(objNode.get("date").asInt())); - scoreData.setPlayer(objNode.get("player").asText()); - scoreData.setSha256(objNode.get("sha256").asText()); - scoreData.setGauge(objNode.get("gauge").asInt()); - scoreData.setEpg(objNode.get("epg").asInt()); - scoreData.setLpg(objNode.get("lpg").asInt()); - scoreData.setEgr(objNode.get("egr").asInt()); - scoreData.setLgr(objNode.get("lgr").asInt()); - scoreData.setEgd(objNode.get("egd").asInt()); - scoreData.setLgd(objNode.get("lgd").asInt()); - scoreData.setEbd(objNode.get("ebd").asInt()); - scoreData.setLbd(objNode.get("lbd").asInt()); - scoreData.setEpr(objNode.get("epr").asInt()); - scoreData.setLpr(objNode.get("lpr").asInt()); - scoreData.setEms(objNode.get("ems").asInt()); - scoreData.setLms(objNode.get("lms").asInt()); - scoreData.setNotes(objNode.get("notes").asInt()); - scoreData.setPassnotes(objNode.get("passnotes").asInt()); - scoreData.setClear(objNode.get("clear").asInt()); - scoreData.setPlaycount(objNode.get("playcount").asInt()); - scoreData.setRandom(objNode.get("random").asInt()); - scoreData.setMinbp(objNode.get("minbp").asInt()); - scoreData.setCombo(objNode.get("maxcombo").asInt()); - scoreData.setMode(0); - - IRScoreData irsc = new IRScoreData(scoreData); - - irScoreDatum.add(irsc); - } - - // weird java oddities: [0] instantiates a list faster than prealloc - IRScoreData[] irScoreArr = irScoreDatum.toArray(new IRScoreData[0]); - - // Beatoraja expects these to be sorted. - Arrays.sort(irScoreArr, (a, b) -> b.getExscore() - a.getExscore()); - - return rc.create(resp.success, resp.description, irScoreArr); + return rc.create(resp.success, resp.description, parseScores(resp.body)); } catch (Exception e) { - log("An error has occurred while fetching scores for " + model.title + " (" + model.sha256 + ")", - Importance.ERROR); + String context = model == null ? "player " + (irpd == null ? "" : irpd.name) + : model.title + " (" + model.sha256 + ")"; + log("An error has occurred while fetching scores for " + context, Importance.ERROR); e.printStackTrace(System.out); - return rc.create(false, "Internal Exception", null); + return rc.create(false, "Internal Exception", new IRScoreData[0]); } } public IRResponse getRivals() { - // Apparently too much strain on the backend for this to work as intended ResponseCreator rc = new ResponseCreator(); - return rc.create(false, "Unimplemented.", new IRPlayerData[0]); + + try { + TachiResponse resp = GETRequest("/ir/beatoraja/rivals"); + + if (!resp.success) { + return rc.create(false, resp.description, new IRPlayerData[0]); + } + + ArrayList rivals = new ArrayList(); + + for (final JsonNode objNode : resp.body) { + rivals.add(new IRPlayerData( + objNode.path("id").asText(), + objNode.path("name").asText(), + objNode.path("rank").asText(""))); + } + + return rc.create(true, resp.description, rivals.toArray(new IRPlayerData[0])); + } catch (Exception e) { + log("An error has occurred while fetching rivals.", Importance.ERROR); + e.printStackTrace(System.out); + return rc.create(false, "Internal Exception", new IRPlayerData[0]); + } } public IRResponse getTableDatas() { @@ -439,9 +492,9 @@ public class TachiIR implements IRConnection { } public IRResponse getCoursePlayData(IRPlayerData irpd, IRCourseData course) { - // This will never be possible in Tachi. + // Tachi stores class achievements for courses, not course PB leaderboards. ResponseCreator rc = new ResponseCreator(); - return rc.create(false, "Unimplemented.", new IRScoreData[0]); + return rc.create(false, "Course rankings are not supported by Tachi.", new IRScoreData[0]); } class ChartResolveRequest { @@ -455,40 +508,48 @@ public class TachiIR implements IRConnection { } public String getSongURL(IRChartData chart) { - String game; - String playtype; + String[] games; switch (chart.mode) { case BEAT_7K: - game = "bms"; - playtype = "7K"; + games = new String[] { "bms-7k" }; break; case BEAT_14K: - game = "bms"; - playtype = "14K"; + games = new String[] { "bms-14k" }; break; case POPN_9K: - // There's no match type for getting a PMS chart from - // its sha256, unfortunately. - return null; + games = new String[] { "pms-controller", "pms-keyboard" }; + break; default: return null; } - try { - ObjectWriter ow = new ObjectMapper().writer(); - String json = ow.writeValueAsString(new ChartResolveRequest("bmsChartHash", chart.sha256)); + for (String game : games) { + String url = getSongURL(game, chart.sha256); - TachiResponse resp = POSTRequest("/api/v1/games/" + game + "/" + playtype + "/charts/resolve", json); + if (url != null) { + return url; + } + } + + return null; + } + + private String getSongURL(String game, String sha256) { + try { + ObjectWriter ow = MAPPER.writer(); + String json = ow.writeValueAsString(new ChartResolveRequest("bmsChartHash", sha256)); + + TachiResponse resp = POSTRequest("/api/v1/games/" + game + "/charts/resolve", json); if (!resp.success) { return null; } - int songID = resp.body.get("song").get("id").asInt(); + String songID = resp.body.get("song").get("id").asText(); String difficulty = resp.body.get("chart").get("difficulty").asText(); - return BASE_URL + "/games/" + game + "/" + playtype + "/songs/" + songID + "/" + difficulty; + return BASE_URL + "/games/" + game + "/songs/" + songID + "/" + difficulty; } catch (Exception e) { log(e.toString(), Importance.ERROR); } diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts index e54aaf774..520ac9d3f 100644 --- a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts +++ b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts @@ -108,4 +108,19 @@ describe("TachiScoreDataToBeatorajaFormat (ported from convert-scores.oldtest.ts expect(res.player).toBe("test_zkldi"); expect(res.epg).toBe(617); }); + + it("sets metadata when supplied", () => { + const res = TachiScoreDataToBeatorajaFormat( + pbScore, + BMSGazerChart.data.hashSHA256, + "test_zkldi", + BMSGazerChart.data.notecount, + 3, + { inputDevice: "BM_CONTROLLER", random: "MIRROR" }, + ); + + expect(res.deviceType).toBe("BM_CONTROLLER"); + expect(res.random).toBe(1); + expect(res.playcount).toBe(3); + }); }); diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.ts index 1bc8c03a4..2e52dcbfe 100644 --- a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.ts +++ b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.ts @@ -2,15 +2,20 @@ import type { BMSGames, integer, PBScoreDocument } from "tachi-common"; const LAMP_TO_BEATORAJA = [0, 1, 3, 4, 5, 6, 7, 8] as const; -// const RAN_INDEXES = { -// NONRAN: 0, -// MIRROR: 1, -// RANDOM: 2, -// "R-RANDOM": 3, -// "S-RANDOM": 4, -// } as const; +const RAN_INDEXES = { + NONRAN: 0, + MIRROR: 1, + RANDOM: 2, + "R-RANDOM": 3, + "S-RANDOM": 4, +} as const; -type BeatorajaJudgements = `${"e" | "l"}${"bd" | "gd" | "gr" | "pg" | "pr"}`; +type BeatorajaScoreMeta = { + inputDevice?: string | null; + random?: keyof typeof RAN_INDEXES | [keyof typeof RAN_INDEXES, keyof typeof RAN_INDEXES] | null; +}; + +type BeatorajaJudgements = `${"e" | "l"}${"bd" | "gd" | "gr" | "ms" | "pg" | "pr"}`; type BeatorajaScoreJudgements = { [K in BeatorajaJudgements]: integer; @@ -50,8 +55,10 @@ export function TachiScoreDataToBeatorajaFormat( username: string, notecount: integer, playcount: integer, + scoreMeta: BeatorajaScoreMeta = {}, ) { const scoreData = pbScore.scoreData; + const random = Array.isArray(scoreMeta.random) ? null : scoreMeta.random; const beatorajaScore: BeatorajaPartialScoreFormat = { sha256, @@ -62,9 +69,8 @@ export function TachiScoreDataToBeatorajaFormat( maxcombo: scoreData.optional.maxCombo ?? 0, gauge: scoreData.optional.gauge ?? 0, - // These two are now unsupported due to performance concerns. - deviceType: null, - random: null, + deviceType: scoreMeta.inputDevice ?? null, + random: random === undefined || random === null ? null : RAN_INDEXES[random], minbp: scoreData.optional.bp ?? 0, passnotes: 0, @@ -73,8 +79,8 @@ export function TachiScoreDataToBeatorajaFormat( const judgements: Partial = {}; - // // Not everything exports these properties. If they're not there, they should default to 0. - // // For cases like LR2/manual - this will just result in a set of 0s. + // Not everything exports these properties. If they're not there, they should default to 0. + // For cases like LR2/manual - this will just result in a set of 0s. for (const key of [ "egd", "lgd", @@ -82,12 +88,13 @@ export function TachiScoreDataToBeatorajaFormat( "lbd", "epr", "lpr", - "ems", - "lms", - ] as Array) { + ] satisfies Array) { judgements[key] = scoreData.optional[key] ?? 0; } + judgements.ems = 0; + judgements.lms = 0; + // // If we have no epg/egr data, we can't calculate EX score on the beatoraja client. // // We have to fake some data for LR2 scores/other scores. // if (!judgements.epg && !judgements.lpg && !judgements.egr && !judgements.lgr) { diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.test.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.test.ts index da02fd6e4..554feca9c 100644 --- a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.test.ts +++ b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.test.ts @@ -111,6 +111,7 @@ describe("GET /ir/beatoraja/charts/:chartSHA256/scores (Postgres)", () => { epg: 617, lpg: 0, player: "", + playcount: 0, }); }); diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts index f59213da3..286c18070 100644 --- a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts +++ b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts @@ -73,8 +73,6 @@ router.get("/scores", async (req, res) => { chart.data.hashSHA256, score.userID === requestingUserID ? "" : username, chart.data.notecount, - - // Playcount is always 0 at the moment due to performance concerns. 0, ), ); diff --git a/typescript/server/src/server/router/ir/beatoraja/router.test.ts b/typescript/server/src/server/router/ir/beatoraja/router.test.ts index da4f11355..cf5ec50a1 100644 --- a/typescript/server/src/server/router/ir/beatoraja/router.test.ts +++ b/typescript/server/src/server/router/ir/beatoraja/router.test.ts @@ -1,4 +1,6 @@ import { seedApiToken, seedUser } from "#actions/test-utils/api-tokens"; +import { newGameProfilePreferenceColumns } from "#lib/game-settings/create-game-settings"; +import { mongoScoreDataToPg } from "#lib/v3/migration-tools"; import DB from "#services/pg/db"; import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; import { @@ -13,6 +15,7 @@ import { afterAll, beforeEach, describe, expect, it } from "vitest"; const NEW_SHA256 = "769359ebb55d3d6dff3b5c6a07ec03be9b87beda1ffb0c07d7ea99590605a732"; const NEW_MD5 = "d0f497c0f955e7edfb0278f446cdb6f8"; +let seedCounter = 0; const IR_HEADERS = { "X-TachiIR-Version": "v2.0.0", @@ -88,6 +91,69 @@ async function seedBmsGazer() { .execute(); } +async function seedBmsProfile(userID: number) { + await DB.insertInto("game_profile") + .values({ + user_id: userID, + game: "bms-7k", + ratings: JSON.stringify({}), + classes: JSON.stringify({}), + ...newGameProfilePreferenceColumns("bms-7k"), + }) + .execute(); +} + +async function seedBeatorajaPb(userID: number, score = 1234) { + const sd = { + score, + enumIndexes: { lamp: 4 }, + optional: { enumIndexes: {}, bp: 12, gauge: 80, maxCombo: 321 }, + judgements: {}, + }; + const { data, derived, judgements } = mongoScoreDataToPg("bms-7k", sd as never); + const n = ++seedCounter; + + await DB.insertInto("pb") + .values({ + user_id: userID, + chart_id: BMSGazerChart.chartID, + lens: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + ranking_value: score, + ranking_value_tb1: null, + ranking_value_tb2: null, + ranking_value_tb3: null, + ranking_value_tb4: null, + ranking_value_tb5: null, + highlight: false, + time_achieved: null, + }) + .execute(); + + await DB.insertInto("score") + .values({ + id: `beatoraja_route_score_${n}`, + user_id: userID, + chart_id: BMSGazerChart.chartID, + game: "bms-7k", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({ inputDevice: "BM_CONTROLLER", random: "MIRROR" }), + time_achieved: null, + time_added: new Date().toISOString(), + highlight: false, + comment: null, + }) + .execute(); +} + describe("POST /ir/beatoraja/submit-score (Postgres)", () => { beforeEach(async () => { await seedUser({ @@ -189,3 +255,110 @@ describe("POST /ir/beatoraja/submit-score (Postgres)", () => { expect(res.status).toBe(401); }); }); + +describe("GET /ir/beatoraja/rivals and /players/:userID/scores (Postgres)", () => { + beforeEach(async () => { + await seedBmsGazer(); + }); + + it("returns Beatoraja-shaped rivals across supported games", async () => { + const { id: mainId } = await seedUser({ username: "beatoraja_rivals_main" }); + const { id: rivalId } = await seedUser({ username: "beatoraja_rivals_rival" }); + await seedBmsProfile(mainId); + await seedBmsProfile(rivalId); + await seedApiToken({ + token: "mock_token", + userId: mainId, + submitScore: true, + }); + + await DB.insertInto("game_rival") + .values({ user_id: mainId, game: "bms-7k", rival: rivalId }) + .execute(); + + const res = await mockApi + .get("/ir/beatoraja/rivals") + .set(IR_HEADERS) + .set("Authorization", "Bearer mock_token"); + + expect(res.status).toBe(200); + expect(res.body.body).toEqual([ + { + id: `${rivalId}`, + name: "beatoraja_rivals_rival", + rank: "", + }, + ]); + }); + + it("exports a user's PBs as Beatoraja scores", async () => { + const { id: mainId } = await seedUser({ username: "beatoraja_scores_main" }); + await seedBmsProfile(mainId); + await seedApiToken({ + token: "mock_token", + userId: mainId, + submitScore: true, + }); + await seedBeatorajaPb(mainId); + + const res = await mockApi + .get(`/ir/beatoraja/players/${mainId}/scores`) + .set(IR_HEADERS) + .set("Authorization", "Bearer mock_token"); + + expect(res.status).toBe(200); + expect(res.body.body[0]).toMatchObject({ + sha256: BMSGazerChart.data.hashSHA256, + player: "", + playcount: 0, + minbp: 12, + gauge: 80, + maxcombo: 321, + }); + }); + + it("allows exporting a configured rival's PBs by username", async () => { + const { id: mainId } = await seedUser({ username: "beatoraja_rival_scores_main" }); + const { id: rivalId } = await seedUser({ username: "beatoraja_rival_scores_rival" }); + await seedBmsProfile(mainId); + await seedBmsProfile(rivalId); + await seedApiToken({ + token: "mock_token", + userId: mainId, + submitScore: true, + }); + await DB.insertInto("game_rival") + .values({ user_id: mainId, game: "bms-7k", rival: rivalId }) + .execute(); + await seedBeatorajaPb(rivalId); + + const res = await mockApi + .get("/ir/beatoraja/players/beatoraja_rival_scores_rival/scores") + .set(IR_HEADERS) + .set("Authorization", "Bearer mock_token"); + + expect(res.status).toBe(200); + expect(res.body.body[0]).toMatchObject({ + player: "beatoraja_rival_scores_rival", + playcount: 0, + }); + }); + + it("rejects exporting unrelated players", async () => { + const { id: mainId } = await seedUser({ username: "beatoraja_unrelated_main" }); + const { id: otherId } = await seedUser({ username: "beatoraja_unrelated_other" }); + await seedBmsProfile(mainId); + await seedApiToken({ + token: "mock_token", + userId: mainId, + submitScore: true, + }); + + const res = await mockApi + .get(`/ir/beatoraja/players/${otherId}/scores`) + .set(IR_HEADERS) + .set("Authorization", "Bearer mock_token"); + + expect(res.status).toBe(403); + }); +}); diff --git a/typescript/server/src/server/router/ir/beatoraja/router.ts b/typescript/server/src/server/router/ir/beatoraja/router.ts index 6e5ec48b4..449c1957b 100644 --- a/typescript/server/src/server/router/ir/beatoraja/router.ts +++ b/typescript/server/src/server/router/ir/beatoraja/router.ts @@ -5,6 +5,11 @@ import type { import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { GetChartById } from "#lib/db-formats/chart"; +import { + type PbDocumentJoinRow, + SELECT_PB_DOCUMENT_WITH_LEADERBOARD, + ToPbScoreDocument, +} from "#lib/db-formats/pb"; import { LoadScoreDocumentById } from "#lib/db-formats/score"; import { GetSongByID } from "#lib/db-formats/song"; import { log } from "#lib/log/log"; @@ -14,19 +19,163 @@ import { RequireNotGuest } from "#server/middleware/auth"; import prValidate from "#server/middleware/prudence-validate"; import DB from "#services/pg/db"; import { UpdateClassIfGreater } from "#utils/class"; -import { IsRecord, NotNullish } from "#utils/misc"; +import { DedupeArr, IsRecord, NotNullish } from "#utils/misc"; +import { GetUsersWithIDs, ResolveUser } from "#utils/user"; import { Router } from "express"; import { sql } from "kysely"; import { p } from "prudence"; -import { type Classes, type GamesForGroup, GameToGameGroup, type integer } from "tachi-common"; +import { + type BMSGames, + type Classes, + type GamesForGroup, + GameToGameGroup, + type integer, + type PBScoreDocument, + type UserDocument, +} from "tachi-common"; import { ValidateIRClientVersion } from "./auth"; +import { TachiScoreDataToBeatorajaFormat } from "./charts/_chartSHA256/convert-scores"; import chartsRouter from "./charts/_chartSHA256/router"; const router: Router = Router({ mergeParams: true }); router.use(ValidateIRClientVersion); +const BEATORAJA_GAMES = ["bms-7k", "bms-14k", "pms-controller", "pms-keyboard"] as const; + +interface BeatorajaPbExportRow extends PbDocumentJoinRow { + chart_data: unknown; +} + +function GetBeatorajaChartData(row: BeatorajaPbExportRow) { + const chartData = row.chart_data as Record; + const sha256 = chartData.hashSHA256; + const notecount = chartData.notecount; + + if (typeof sha256 !== "string" || typeof notecount !== "number") { + log.warn( + { chartID: row.chart_id, chartData }, + `Skipping Beatoraja PB export row with invalid chart data.`, + ); + return null; + } + + return { notecount, sha256 }; +} + +async function GetBeatorajaRivalUsers(userID: integer): Promise { + const rivalRows = await DB.selectFrom("game_rival") + .select("game_rival.rival") + .where("game_rival.user_id", "=", userID) + .where("game_rival.game", "in", BEATORAJA_GAMES) + .execute(); + + const rivalIDs = DedupeArr(rivalRows.map((r) => r.rival)); + + return GetUsersWithIDs(rivalIDs); +} + +async function GetPermittedBeatorajaUserIDs(userID: integer) { + return new Set([userID, ...(await GetBeatorajaRivalUsers(userID)).map((r) => r.id)]); +} + +async function LoadBeatorajaPbsForUser(userID: integer): Promise { + const rows = await DB.selectFrom("pb") + .innerJoin("chart_leaderboard", "chart_leaderboard.row_id", "pb.row_id") + .innerJoin("chart", "chart.id", "pb.chart_id") + .innerJoin("song", "song.id", "chart.song_id") + .select([...SELECT_PB_DOCUMENT_WITH_LEADERBOARD, "chart.data as chart_data"]) + .where("pb.user_id", "=", userID) + .where("chart.game", "in", BEATORAJA_GAMES) + .where("pb.lens", "is", null) + .orderBy("pb.time_achieved", "desc") + .execute(); + + return rows as BeatorajaPbExportRow[]; +} + +async function FormatBeatorajaPbsForUser(user: UserDocument, requestedBy: integer) { + const rows = await LoadBeatorajaPbsForUser(user.id); + const scores = await Promise.all( + rows.map(async (row) => { + const chartData = GetBeatorajaChartData(row); + + if (!chartData) { + return null; + } + + const pb = await ToPbScoreDocument(row); + + return TachiScoreDataToBeatorajaFormat( + pb as PBScoreDocument, + chartData.sha256, + user.id === requestedBy ? "" : user.username, + chartData.notecount, + 0, + ); + }), + ); + + return scores.filter((score) => score !== null); +} + +/** + * Returns all configured Tachi rivals that can be represented through beatoraja's + * game-wide rival API. + * + * @name GET /ir/beatoraja/rivals + */ +router.get("/rivals", async (req, res) => { + const userID = NotNullish(req[SYMBOL_TACHI_API_AUTH].userID); + const rivals = await GetBeatorajaRivalUsers(userID); + + return res.status(200).json({ + success: true, + description: `Returned ${rivals.length} rivals.`, + body: rivals.map((rival) => ({ + id: `${rival.id}`, + name: rival.username, + rank: "", + })), + }); +}); + +/** + * Exports every Beatoraja-compatible PB for a player. The beatoraja client uses + * this for initial local score import and rival score database hydration. + * + * @name GET /ir/beatoraja/players/:userID/scores + */ +router.get("/players/:userID/scores", async (req, res) => { + const userID = NotNullish(req[SYMBOL_TACHI_API_AUTH].userID); + const user = await ResolveUser(req.params.userID); + + if (!user) { + return res.status(404).json({ + success: false, + description: `User does not exist.`, + }); + } + + const permittedUserIDs = await GetPermittedBeatorajaUserIDs(userID); + + if (!permittedUserIDs.has(user.id)) { + return res.status(403).json({ + success: false, + description: `Cannot export scores for a player that is not you or your rival.`, + }); + } + + const scores = await FormatBeatorajaPbsForUser(user, userID); + + return res.status(200).json({ + success: true, + description: `Successfully returned ${scores.length} scores.`, + body: scores, + }); +}); + /** * Submits a beatoraja score to Tachi. * @@ -275,8 +424,8 @@ router.post( const combinedMD5s = charts.map((e) => e.md5).join(""); const course = await DB.selectFrom("bms_course_lookup") - .select(["set", "game", "value"]) - .where("md5sums", "=", combinedMD5s) + .select(["bms_course_lookup.set", "bms_course_lookup.game", "bms_course_lookup.value"]) + .where("bms_course_lookup.md5sums", "=", combinedMD5s) .executeTakeFirst(); if (!course) {