feat: flesh out all the rest of the IR options in beatoraja (#1548)

* feat: flesh out all the rest of the IR options in beatoraja

* fix: fix
This commit is contained in:
zk
2026-05-21 22:10:11 +01:00
committed by GitHub
parent fdef040a74
commit 7ac1b3d657
8 changed files with 512 additions and 108 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ java {
}
group = "xyz.zkldi.bokutachiIR"
version = "3.1.2"
version = "4.0.0"
repositories {
mavenCentral()
@@ -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<IRPlayerData> register(IRAccount account) {
return null;
ResponseCreator<IRPlayerData> rc = new ResponseCreator<IRPlayerData>();
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<IRScoreData> irScoreDatum = new ArrayList<IRScoreData>();
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<IRScoreData[]> rc = new ResponseCreator<IRScoreData[]>();
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<IRScoreData> irScoreDatum = new ArrayList<IRScoreData>();
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 ? "<none>" : 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<IRPlayerData[]> getRivals() {
// Apparently too much strain on the backend for this to work as intended
ResponseCreator<IRPlayerData[]> rc = new ResponseCreator<IRPlayerData[]>();
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<IRPlayerData> rivals = new ArrayList<IRPlayerData>();
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<IRTableData[]> getTableDatas() {
@@ -439,9 +492,9 @@ public class TachiIR implements IRConnection {
}
public IRResponse<IRScoreData[]> getCoursePlayData(IRPlayerData irpd, IRCourseData course) {
// This will never be possible in Tachi.
// Tachi stores class achievements for courses, not course PB leaderboards.
ResponseCreator<IRScoreData[]> rc = new ResponseCreator<IRScoreData[]>();
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);
}
@@ -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);
});
});
@@ -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<BeatorajaScoreJudgements> = {};
// // 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<BeatorajaJudgements>) {
] satisfies Array<BeatorajaJudgements>) {
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) {
@@ -111,6 +111,7 @@ describe("GET /ir/beatoraja/charts/:chartSHA256/scores (Postgres)", () => {
epg: 617,
lpg: 0,
player: "",
playcount: 0,
});
});
@@ -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,
),
);
@@ -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);
});
});
@@ -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<string, unknown>;
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<UserDocument[]> {
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<BeatorajaPbExportRow[]> {
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<BMSGames>,
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) {