feat: arcaea 7.0 (#1736)

* fucking lowirommmmmmmmmmmmmmm

* I gently open the repo

* eagles can fly, people can try, but that's about it

* I still hate you

* potential merger test

* remove raw b50

* touchup

* less insane handling of lamps

* add sacrosanct
This commit is contained in:
nairobi
2026-09-16 20:44:45 +01:00
committed by GitHub
parent 0cd0c4979f
commit bc9d2789bc
21 changed files with 1704 additions and 32 deletions
@@ -0,0 +1,16 @@
-- Migrate existing orphans to arcaeaInGameStrID
UPDATE orphan_score
SET data = jsonb_set(data, '{difficulty}', '"AnyBeyond"')
WHERE data->>'difficulty' = 'Beyond'
AND context->>'game' = 'arcaea';
UPDATE orphan_score
SET data = jsonb_set(data, '{matchType}', '"arcaeaInGameStrID"')
WHERE data->>'matchType' = 'inGameStrID'
AND context->>'game' = 'arcaea';
-- Recalc everything because of the +0.2 clear bonus
INSERT INTO score_rederive (chart_id)
SELECT id as chart_id
FROM chart
WHERE chart.game = 'arcaea';
File diff suppressed because it is too large Load Diff
+279
View File
@@ -8258,5 +8258,284 @@
"코스모자이랄"
],
"title": "Cosmogyral"
},
{
"altTitles": [
"ザラメノキワミ"
],
"artist": "STEAKA",
"data": {
"songPack": "Memory Archive"
},
"id": "S1a048225d2313463f74",
"legacySongID": 549,
"searchTerms": [
"zaramenokiwami",
"수크로마니아",
"자라메노키와미"
],
"title": "Sucromania"
},
{
"altTitles": [
"神っぽいな"
],
"artist": "ピノキオピー",
"data": {
"songPack": "Virtual Singers"
},
"id": "S1a048225d240aa071b9",
"legacySongID": 551,
"searchTerms": [
"kamippoina",
"かみっぽいな",
"신 같네",
"카밋포이나",
"카밋뽀이나",
"가디시"
],
"title": "God-ish"
},
{
"altTitles": [
"テレパシ"
],
"artist": "DECO*27",
"data": {
"songPack": "Virtual Singers"
},
"id": "S1a048225d24215b10b2",
"legacySongID": 553,
"searchTerms": [
"terepashi",
"텔레파시"
],
"title": "Telepathy"
},
{
"altTitles": [
"ラヴィ"
],
"artist": "すりぃ",
"data": {
"songPack": "Virtual Singers"
},
"id": "S1a048225d24796e8484",
"legacySongID": 550,
"searchTerms": [
"ravi",
"라비"
],
"title": "Lavie"
},
{
"altTitles": [
"オーバーライド"
],
"artist": "吉田夜世",
"data": {
"songPack": "Virtual Singers"
},
"id": "S1a048225d24bb967053",
"legacySongID": 552,
"searchTerms": [
"obaraido",
"오버라이드"
],
"title": "Override"
},
{
"altTitles": [
"愛して愛して愛して"
],
"artist": "きくお",
"data": {
"songPack": "Virtual Singers"
},
"id": "S1a048225d24f53846c6",
"legacySongID": 554,
"searchTerms": [
"aishiteaishiteaishite",
"あいしてあいしてあいして",
"사랑해줘 사랑해줘 사랑해줘",
"아이시테 아이시테 아이시테",
"러브 미 러브 미 러브 미"
],
"title": "Love me, Love me, Love me"
},
{
"altTitles": [],
"artist": "Yuta Imai",
"data": {
"songPack": "Memory Archive"
},
"id": "S1a048225d253cdc0e43",
"legacySongID": 556,
"searchTerms": [
"あしっどごっど",
"애시드 갓"
],
"title": "Acid God"
},
{
"altTitles": [],
"artist": "tn-shi",
"data": {
"songPack": "Memory Archive"
},
"id": "S1a048225d2548f19015",
"legacySongID": 555,
"searchTerms": [
"しんせしす",
"신테시스",
"신세시스"
],
"title": "Synthesis."
},
{
"altTitles": [],
"artist": "TAK x Sobrem",
"data": {
"songPack": "Memory Archive"
},
"id": "S1a048225d25e2544c0b",
"legacySongID": 557,
"searchTerms": [
"だいいん",
"だいん",
"다이 인"
],
"title": "DIE IN"
},
{
"altTitles": [],
"artist": "lowiro Sound Team (ak+q x nitro)",
"data": {
"songPack": "Memory Archive"
},
"id": "S1a048225d26066ab5a6",
"legacySongID": 558,
"searchTerms": [
"くろのろじあ",
"크로노로지아"
],
"title": "chronologia"
},
{
"altTitles": [],
"artist": "黒魔",
"data": {
"songPack": "Divine Oblivion"
},
"id": "S1a048225d260ce5389b",
"legacySongID": 561,
"searchTerms": [
"あん",
"언",
"은",
"운"
],
"title": "un"
},
{
"altTitles": [],
"artist": "Powerless & siqlo feat. Tia",
"data": {
"songPack": "Divine Oblivion"
},
"id": "S1a048225d260e77c468",
"legacySongID": 565,
"searchTerms": [
"えんばー",
"엠버"
],
"title": "Ember"
},
{
"altTitles": [],
"artist": "Ashrount vs. 打打だいず",
"data": {
"songPack": "Divine Oblivion"
},
"id": "S1a048225d260ef2b185",
"legacySongID": 560,
"searchTerms": [
"どれっどえりあ",
"드레드 에어리어"
],
"title": "DREAD AREA"
},
{
"altTitles": [],
"artist": "rejection & Supire feat. eili",
"data": {
"songPack": "Divine Oblivion"
},
"id": "S1a048225d263a3bf06d",
"legacySongID": 562,
"searchTerms": [
"りべんぴるぐりめーじ",
"리벤 필그러메이지"
],
"title": "Riven Pilgrimage"
},
{
"altTitles": [],
"artist": "Redsign",
"data": {
"songPack": "Divine Oblivion"
},
"id": "S1a048225d263c54c554",
"legacySongID": 559,
"searchTerms": [
"ばろーる",
"ばろる",
"발러",
"밸러"
],
"title": "Balor"
},
{
"altTitles": [],
"artist": "Blacklolita vs Juggernaut.",
"data": {
"songPack": "Divine Oblivion"
},
"id": "S1a048225d2652d61d6b",
"legacySongID": 564,
"searchTerms": [
"えいのすふぁいねいん",
"에이노스 파이네인"
],
"title": "DEINOS PHAINEIN"
},
{
"altTitles": [],
"artist": "Aid",
"data": {
"songPack": "Divine Oblivion"
},
"id": "S1a048225d26d1dcad1e",
"legacySongID": 563,
"searchTerms": [
"かたくりずむくらい",
"카타클리즘 시티"
],
"title": "Cataclysm Cry"
},
{
"altTitles": [],
"artist": "Silentroom Dismantled XOR かたぎり",
"data": {
"songPack": "Divine Oblivion"
},
"id": "S1a06240e55220088ac4",
"legacySongID": 566,
"searchTerms": [
"sacrosanct",
"さくろさんくと",
"사크로상트"
],
"title": "⊥⊬"
}
]
+11
View File
@@ -168,6 +168,17 @@ means that it will check for any of `INF/GRV/HVN/VVD/XCD` for this song.
This is useful for services that store all of those as the same difficulty.
- arcaeaInGameStrID
This uses the in-game-ID for this Arcaea song. You must specify
the difficulty for this chart as well.
The reason Arcaea gets its own special `matchType` is because this
matchType supports `difficulty: "AnyBeyond"`. This special difficulty
means that it will check for any of `Beyond/Inscribed` for this song.
This is useful for services that store all of those as the same difficulty.
- ddrSongHash
This is a 32-character hash used by the official DDR e-amusement website to
@@ -45,6 +45,12 @@ Looks up on the in game ID for this chart, but allows a special difficulty strin
If `"ANY_INF"` is the difficulty, then that difficulty will try to find a chart with this in game ID where the difficulty is any of INF, GRV, HVN, VVD or XCD.
### `arcaeaInGameStrID`
Looks up on the in game ID for this chart, but allows a special difficulty string - `"AnyBeyond"` to be passed.
If `"AnyBeyond"` is the difficulty, then that difficulty will try to find a chart with this in game ID where the difficulty is any of Beyond or Inscribed.
### `songTitle`
Looks up the song on its title or any of its defined `altTitles`. Can also match the artist field of the song if an `artist` property is defined.
+6 -3
View File
@@ -50,21 +50,24 @@ The following judgements are defined:
| Name | Description |
| :: | :: |
| `naivePotential` | The average of your best 10 potentials this session. |
| `naivePotential` | The average of your best 10 Potentials this session. |
### Profile Rating Algorithms
| Name | Description |
| :: | :: |
| `naivePotential` | The average of your best 30 potential values. This is different to in-game, as it does not take into account your recent scores in any way. |
| `naivePotential` | The average of your best 50 Potential values, with the best 10 being counted twice. Named naivePotential for legacy reasons, but it's actually Potential as implemented in Arcaea. |
## Difficulties
`Beyond` and `Inscribed` can use the alias `AnyBeyond` for imports using `arcaeaInGameStrID`.
- `Past`
- `Present`
- `Future`
- `Eternal`
- `Beyond`
- `Inscribed`
## Classes
@@ -82,6 +85,6 @@ The following judgements are defined:
## Supported Match Types
- `inGameStrID`
- `arcaeaInGameStrID`
- `songTitle`
- `tachiSongID`
@@ -64,6 +64,10 @@ export default function BatchManualPage() {
const game = ResolveBatchManualGameFromMeta(data.meta);
if (!("service" in data.meta)) {
throw new Error("Invalid BATCH-MANUAL: Missing service name");
}
const err = p(data, PR_BATCH_MANUAL(game));
if (err) {
@@ -22,6 +22,7 @@ const ARCAEA_DIFFICULTY_COLORS: GameClientImplementation<
Future: COLOUR_SET.purple,
Eternal: COLOUR_SET.paleBlue,
Beyond: COLOUR_SET.vibrantRed,
Inscribed: COLOUR_SET.vibrantBlue,
};
const ARCAEA_ENUM_COLORS: GameClientImplementation<GamesForGroup["arcaea"]>["enumColours"] = {
@@ -162,6 +163,11 @@ export const ARCAEA_TOUCH_IMPL: GameClientImplementation<"arcaea"> = {
grade: "sort-alpha-up",
lamp: "lightbulb",
},
ratingAlgNameOverrides: {
profile: {
naivePotential: "Potential",
},
},
enumColours: ARCAEA_ENUM_COLORS,
classColours: ARCAEA_COLORS,
difficultyColours: ARCAEA_DIFFICULTY_COLORS,
@@ -23,7 +23,7 @@ const getDifficulty = (score: ArcaeaST3ScoreRow) => {
case 2:
return "Future";
case 3:
return "Beyond";
return "AnyBeyond";
case 4:
return "Eternal";
default:
@@ -100,7 +100,7 @@ export function convertArcaeaDB(db: Database): { result: ArcaeaBatchManual; warn
}
scores.push({
identifier: row.songId,
matchType: "inGameStrID",
matchType: "arcaeaInGameStrID",
difficulty: getDifficulty(row),
score: row.score,
lamp: getLamp(row),
@@ -82,21 +82,21 @@ export const GAME_ARCAEA_CONF = {
scoreRatingAlgs: {
potential: {
description: "Potential as it is implemented in Arcaea.",
formatter: ToDecimalPlaces(2),
formatter: ToDecimalPlaces(3),
canSetGoalsOn: true,
},
},
sessionRatingAlgs: {
naivePotential: {
description: "The average of your best 10 potentials this session.",
formatter: ToDecimalPlaces(2),
description: "The average of your best 10 Potentials this session.",
formatter: ToDecimalPlaces(3),
},
},
profileRatingAlgs: {
naivePotential: {
description:
"The average of your best 30 Potential values. This is different to the in-game algorithm, as it does not take your recent scores into account in any way.",
formatter: (v) => (Math.round(v * 100.0) / 100.0).toFixed(2),
"The average of your best 50 Potential values, with the best 10 being counted twice. Profile Potential as it's implemented in Arcaea 7.0+.",
formatter: (v) => (Math.round(v * 1000.0) / 1000.0).toFixed(3),
associatedScoreAlgs: ["potential"],
},
},
@@ -107,13 +107,14 @@ export const GAME_ARCAEA_CONF = {
difficulties: {
type: "FIXED",
order: ["Past", "Present", "Future", "Eternal", "Beyond"],
order: ["Past", "Present", "Future", "Eternal", "Beyond", "Inscribed"],
formatShort: {
Past: "PST",
Present: "PRS",
Future: "FTR",
Eternal: "ETR",
Beyond: "BYD",
Inscribed: "INS",
},
formatLong: {},
default: "Future",
@@ -147,5 +148,5 @@ export const GAME_ARCAEA_CONF = {
preferences: z.strictObject({}),
scoreMeta: z.strictObject({}),
supportedMatchTypes: ["inGameStrID", "songTitle", "tachiSongID"],
supportedMatchTypes: ["arcaeaInGameStrID", "songTitle", "tachiSongID"],
} as const satisfies INTERNAL_GAME_CONFIG;
+1
View File
@@ -39,6 +39,7 @@ const PR_BATCH_MANUAL_SCORE = (game: V3Game): PrudenceSchema => {
"uscChartHash",
"popnChartHash",
"ddrSongHash",
"arcaeaInGameStrID",
),
identifier: "string",
comment: optNull(p.isBoundedString(3, 240)),
@@ -21,6 +21,7 @@ type MatchTypesNoDifficulty =
// These MatchTypes need `difficulty` set in the batch manual.
type MatchTypesWithDifficulty =
| "arcaeaInGameStrID"
| "ddrSongHash"
| "inGameID"
| "inGameStrID"
@@ -1,16 +1,27 @@
import { expect, test } from "vitest";
import { isAprx } from "../test-utils/approx";
import { TestCase } from "../test-utils/test-case";
import { calculate } from "./potential";
import { ArcaeaLamp, calculate } from "./potential";
test("Arcaea Potential Tests", () => {
function MakeTestCase(score: number, level: number, expectedPotential: number): TestCase {
return () =>
return () => {
const clearLamps: ArcaeaLamp[] = ["EASY CLEAR", "CLEAR", "HARD CLEAR", "FULL RECALL"];
isAprx(
calculate(score, level),
calculate(score, level, "LOST"),
expectedPotential,
`A score of ${score} on a chart of level ${level} should be worth ${expectedPotential}`,
`A LOST score of ${score} on a chart of level ${level} should be worth roughly ${expectedPotential}`,
2,
);
clearLamps.forEach((lamp) => {
isAprx(
calculate(score, level, lamp),
expectedPotential + 0.2,
`A ${lamp} score of ${score} on a chart of level ${level} should be worth roughly ${expectedPotential + 0.2}`,
2,
);
});
};
}
const testCases = [
@@ -19,7 +30,6 @@ test("Arcaea Potential Tests", () => {
MakeTestCase(9_932_746, 10.9, 12.56),
MakeTestCase(9_805_015, 11.6, 12.63),
MakeTestCase(10_000_000, 7.0, 9.0),
MakeTestCase(9_900_000, 8.0, 9.5),
MakeTestCase(9_800_000, 9.5, 10.5),
MakeTestCase(9_500_000, 8.9, 8.9),
@@ -32,8 +42,22 @@ test("Arcaea Potential Tests", () => {
testCase();
}
isAprx(
calculate(10_000_000, 7.0, "PURE MEMORY"),
9.2,
`A PURE MEMORY on a chart of level 7.0 should be worth 9.2`,
3,
);
expect(() => calculate(9_999_999, 7.0, "PURE MEMORY")).toThrow(
/.*PURE MEMORY cannot be below 10 million.*/u,
);
expect(() => calculate(10_000_000, 7.0, "CLEAR")).toThrow(
/.*Scores exceeding 10 million must be PURE MEMORY.*/u,
);
expect(
calculate(10_000_000, 10.0),
calculate(10_000_000, 10.0, "PURE MEMORY"),
"Anything above 10,000,000 should give identical potential.",
).toBe(calculate(10_001_000, 10.0));
).toBe(calculate(10_001_000, 10.0, "PURE MEMORY"));
});
@@ -1,16 +1,35 @@
import { ThrowIf } from "../util/throw-if";
export type ArcaeaLamp =
| "LOST"
| "CLEAR"
| "EASY CLEAR"
| "HARD CLEAR"
| "FULL RECALL"
| "PURE MEMORY";
/**
* Calculate Arcaea potential for a score.
*
* @param score - The score to calculate the potential for.
* @param internalChartLevel - The internal decimal level of the chart the score was achieved on.
* @param lamp - The lamp of this score. As of Arcaea 7.0, this determines the +0.2 clear bonus.
*/
export function calculate(score: number, internalChartLevel: number) {
export function calculate(score: number, internalChartLevel: number, lamp: ArcaeaLamp) {
ThrowIf.negative(score, "Score cannot be negative.", { score });
ThrowIf.negative(internalChartLevel, "Internal chart level cannot be negative.", {
level: internalChartLevel,
});
ThrowIf(
lamp === "PURE MEMORY" && score < 10_000_000,
"PURE MEMORY cannot be below 10 million.",
{ score, lamp },
);
ThrowIf(
lamp !== "PURE MEMORY" && score >= 10_000_000,
"Scores exceeding 10 million must be PURE MEMORY.",
{ score, lamp },
);
let potential = 0;
@@ -22,5 +41,9 @@ export function calculate(score: number, internalChartLevel: number) {
potential = internalChartLevel + (score - 9_500_000) / 300_000;
}
if (lamp !== "LOST") {
potential += 0.2;
}
return Math.max(potential, 0);
}
@@ -22,6 +22,7 @@ type LocalizedSearchTerms = Record<string, Array<string>>;
interface SonglistChart {
ratingClass: integer;
ratingClassAlias?: integer;
title_localized?: LocalizedText;
artist?: string;
audioOverride?: boolean;
@@ -93,7 +94,7 @@ class MultiMapUniqueValues<K, V> {
}
}
function convertDifficulty(input: integer): Difficulties["arcaea"] {
function convertDifficulty(input: integer, alias?: integer): Difficulties["arcaea"] {
switch (input) {
case 0:
return "Past";
@@ -102,7 +103,11 @@ function convertDifficulty(input: integer): Difficulties["arcaea"] {
case 2:
return "Future";
case 3:
return "Beyond";
if (alias === 1) {
return "Inscribed";
} else {
return "Beyond";
}
case 4:
return "Eternal";
}
@@ -227,7 +232,7 @@ for (const entry of data.songs) {
continue;
}
const difficulty = convertDifficulty(chart.ratingClass);
const difficulty = convertDifficulty(chart.ratingClass, chart.ratingClassAlias);
const exists = existingCharts.get(`${inGameID}-${difficulty}`);
if (exists) {
@@ -29,6 +29,7 @@ const inverseConvertDifficulty = (input: string) => {
case "Future":
return 2;
case "Beyond":
case "Inscribed":
return 3;
case "Eternal":
return 4;
@@ -68,6 +68,18 @@ const MATCH_TYPE_CHECKS: Record<
return `${c.data.inGameID}-${diff}`;
},
},
arcaeaInGameStrID: {
type: "CHARTS",
fn: (c) => {
let diff = c.difficulty;
if (["Beyond", "Inscribed"].includes(diff)) {
diff = "AnyBeyond";
}
return `${c.data.inGameStrID}-${diff}`;
},
},
uscChartHash: { type: "CHARTS", fn: (c) => c.data.hashSHA1 },
ddrSongHash: {
type: "SONGS",
@@ -126,7 +138,7 @@ for (const { game, matchType } of uniquenessChecks) {
}
if (
matchType === "inGameStrID" &&
(matchType === "inGameStrID" || matchType === "arcaeaInGameStrID") &&
el.data.inGameStrID === null &&
el.data.inGameStrID === undefined
) {
@@ -37,7 +37,7 @@ const baseMetrics: MongoProvidedMetrics["arcaea"] = {
};
const scoreData: ScoreData<"arcaea"> = {
lamp: "CLEAR",
lamp: "LOST",
score: 9_979_366,
grade: "EX+",
judgements: {
@@ -48,7 +48,7 @@ const scoreData: ScoreData<"arcaea"> = {
optional: { enumIndexes: {} },
enumIndexes: {
grade: ARCAEA_GRADES.EX_PLUS,
lamp: ARCAEA_LAMPS.CLEAR,
lamp: ARCAEA_LAMPS.LOST,
},
};
@@ -136,7 +136,7 @@ describe("ARCAEA_IMPL", () => {
expect(
ARCAEA_IMPL.scoreCalcs(scoreData, ARCAEA_IMPL.scoreDeriver(scoreData, chart), chart)
.potential,
).toBe(11.99683);
).toBeCloseTo(11.99683, 3);
});
describe("classDerivers (naivePotential → badge)", () => {
@@ -226,6 +226,8 @@ describe("ARCAEA_IMPL", () => {
const pb = await CreatePBDoc("arcaea", userId, chart, log);
expect(pb?.calculatedData.potential).toBeCloseTo(12.19683, 3);
expect(pb).toMatchObject({
composedFrom: [{ name: "Best Score" }, { name: "Best Lamp", scoreID: "bestLamp" }],
scoreData: {
@@ -1,6 +1,6 @@
import { type GameImplementation } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc";
import { ProfileSumBestN } from "#game-implementations/utils/profile-calc";
import { SessionAvgBest10For } from "#game-implementations/utils/session-calc";
import { IsNullish } from "#utils/misc";
import { Potential } from "rg-stats";
@@ -24,7 +24,10 @@ export const ARCAEA_IMPL: GameImplementation<"arcaea"> = {
grade: GetGrade(ARCAEA_GBOUNDARIES, scoreData.score),
}),
scoreCalcs: (scoreData, _derivedData, chart) => ({
potential: chart.levelNum > 0 ? Potential.calculate(scoreData.score, chart.levelNum) : 0,
potential:
chart.levelNum > 0
? Potential.calculate(scoreData.score, chart.levelNum, scoreData.lamp)
: 0,
}),
pbRankingValues: (pb) => ({
ranking: pb.scoreData.score,
@@ -38,7 +41,10 @@ export const ARCAEA_IMPL: GameImplementation<"arcaea"> = {
naivePotential: SessionAvgBest10For("potential")(arr),
}),
profileCalcs: async (game, userID) => ({
naivePotential: await ProfileAvgBestN("potential", 30)(game, userID),
naivePotential:
(((await ProfileSumBestN("potential", 50)(game, userID)) ?? 0) +
((await ProfileSumBestN("potential", 10)(game, userID)) ?? 0)) /
60,
}),
classDerivers: (ratings) => {
const potential = ratings.naivePotential;
@@ -15,6 +15,7 @@ import {
staticAssertUnreachable,
} from "#utils/misc";
import {
FindArcaeaChartOnInGameStrID,
FindBMSChartOnHash,
FindChartOnInGameIDIfUnique,
FindChartOnInGameIDPrimary,
@@ -441,6 +442,53 @@ export async function ResolveSongAndChart(
return { song, chart };
}
case "arcaeaInGameStrID": {
const identifier = resolver.identifier;
const config = GetGameConfig("arcaea");
if (config.difficulties.type === "DYNAMIC") {
log.error(
{
config,
},
`Arcaea has 'DYNAMIC' difficulties set. This is completely unexpected.`,
);
throw new ScoreImportFatalError(
500,
`Arcaea has 'DYNAMIC' difficulties set. This is completely unexpected.`,
);
}
if (
!config.difficulties.order.includes(resolver.difficulty) &&
resolver.difficulty !== "AnyBeyond"
) {
throw new InvalidScoreFailure(
`Invalid difficulty '${
resolver.difficulty
}', Expected any of ${config.difficulties.order.join(", ")} or AnyBeyond`,
);
}
const diff = resolver.difficulty as "AnyBeyond" | Difficulties["arcaea"];
const chart = await FindArcaeaChartOnInGameStrID(identifier, diff);
if (!chart) {
return null;
}
const song = await FindSongOnID("arcaea", chart.song.id);
if (!song) {
log.error(`Song-Chart desync on ${chart.song.id}.`);
throw new InternalFailure(`Failed to get song for a chart that exists.`);
}
return { song, chart };
}
case "uscChartHash": {
if (gameGroup !== "usc") {
throw new InvalidScoreFailure(`uscChartHash matchType can only be used on USC.`);
@@ -563,6 +563,39 @@ export async function FindChartOnInGameStrIDVersion<TGame extends V3Game = V3Gam
return ToChartDocument(row);
}
const ARCAEA_BEYOND_DIFFS = ["Beyond", "Inscribed"] as const;
/**
* Find an Arcaea Chart on its in game string ID. This exists to handle
* oddities with Arcaea difficulties - If "AnyBeyond" is sent, it actually
* refers to any of Beyond or Inscribed. This is because some services treat
* all of those as the same difficulty, but we do not.
*/
export async function FindArcaeaChartOnInGameStrID(
inGameStrID: string,
difficulty: "AnyBeyond" | Difficulties[GamesForGroup["arcaea"]],
) {
let q = DB.selectFrom("chart")
.innerJoin("song", "song.id", "chart.song_id")
.select(SELECT_CHART)
.where("chart.game", "=", "arcaea")
.where(sql<boolean>`(chart.data::jsonb->>'inGameStrID') = ${inGameStrID}`)
.where("chart.is_primary", "=", true);
q =
difficulty === "AnyBeyond"
? q.where("chart.difficulty", "in", [...ARCAEA_BEYOND_DIFFS])
: q.where("chart.difficulty", "=", difficulty);
const row = await q.executeTakeFirst();
if (!row) {
return null;
}
return ToChartDocument(row);
}
/**
* Finds an IIDX chart on its 2dxtra hash, which is the sha256 of the .1 buffer.
*/