From 89eac259a8581cb4971485e3d2afa0fc2b663c0a Mon Sep 17 00:00:00 2001 From: zkldi Date: Thu, 13 May 2021 22:25:53 +0100 Subject: [PATCH] Baseline untested Fervidex support --- server/src/api/ir/fervidex/fervidex.ts | 49 ++++ .../import-types/ir/fervidex/converter.ts | 211 ++++++++++++++++++ .../import-types/ir/fervidex/parser.ts | 62 +++++ .../import-types/ir/fervidex/types.ts | 46 ++++ 4 files changed, 368 insertions(+) create mode 100644 server/src/api/ir/fervidex/fervidex.ts create mode 100644 server/src/score-import/import-types/ir/fervidex/converter.ts create mode 100644 server/src/score-import/import-types/ir/fervidex/parser.ts create mode 100644 server/src/score-import/import-types/ir/fervidex/types.ts diff --git a/server/src/api/ir/fervidex/fervidex.ts b/server/src/api/ir/fervidex/fervidex.ts new file mode 100644 index 000000000..fd958b4fd --- /dev/null +++ b/server/src/api/ir/fervidex/fervidex.ts @@ -0,0 +1,49 @@ +import { Router, RequestHandler } from "express"; +import { RequireLoggedIn } from "../../../middleware/require-logged-in"; + +const router: Router = Router({ mergeParams: true }); + +const RequireInf2Header: RequestHandler = async (req, res, next) => { + let swModel = req.header("X-Software-Model"); + + if (!swModel || !swModel.startsWith("P2D:J:B:A")) { + return res.status(400).send(); + } + + return next(); +}; + +/** + * Submits all of a users data to Kamaitachi. This data is extremely minimal, + * as only a users Lamp and Score are sent. As such, this is not the prefered + * way of syncing scores outside of INF2, where there is no other way to + * retrieve scores. + * + * @name /api/ir/fervidex/profile/submit + */ +router.post("/profile/submit", RequireLoggedIn, RequireInf2Header, async (req, res) => { + throw new Error("Unimplemented."); +}); + +/** + * Submits a single score to Kamaitachi. In contrast to profile/submit, this + * sends the most data (and most accurate data) of any score hook. + * As such, this is the preferred way of submitting IIDX scores to Kamaitachi. + * + * @name /api/ir/fervidex/score/submit + */ +router.post("/score/submit", RequireLoggedIn, async (req, res) => { + throw new Error("Unimplemented."); +}); + +/** + * Submits the result of a class to Kamaitachi. This contains the dan played + * and whether it was achieved. + * + * @name /api/ir/fervidex/class/submit + */ +router.post("/class/submit", RequireLoggedIn, async (req, res) => { + throw new Error("Unimplemented."); +}); + +export default router; diff --git a/server/src/score-import/import-types/ir/fervidex/converter.ts b/server/src/score-import/import-types/ir/fervidex/converter.ts new file mode 100644 index 000000000..bc91e2440 --- /dev/null +++ b/server/src/score-import/import-types/ir/fervidex/converter.ts @@ -0,0 +1,211 @@ +import { FindSongOnID } from "../../../../common/database-lookup/song"; +import { ConverterFunction, DryScore, EmptyObject } from "../../../../types"; +import { + GenericCalculatePercent, + GetGradeFromPercent, +} from "../../../framework/common/score-utils"; +import { + InternalFailure, + InvalidScoreFailure, + KTDataNotFoundFailure, +} from "../../../framework/score-importing/converter-failures"; +import { FervidexScore } from "./types"; +import { Lamps, Grades, Difficulties, Playtypes } from "kamaitachi-common"; +import { FindChartOnInGameID } from "../../../../common/database-lookup/chart"; + +const LAMP_LOOKUP = { + 0: "NO PLAY", + 1: "FAILED", + 2: "ASSIST CLEAR", + 3: "EASY CLEAR", + 4: "CLEAR", + 5: "HARD CLEAR", + 6: "EX HARD CLEAR", + 7: "FULL COMBO", +}; + +function KtchifyAssist( + assist: FervidexScore["option"]["assist"] +): DryScore<"iidx:SP" | "iidx:DP">["scoreMeta"]["assist"] { + switch (assist) { + case "ASCR_LEGACY": + return "FULL ASSIST"; + case "AUTO_SCRATCH": + return "AUTO SCRATCH"; + case "FULL_ASSIST": + return "FULL ASSIST"; + case "LEGACY_NOTE": + return "LEGACY NOTE"; + case null: + case undefined: + return "NO ASSIST"; + } +} + +function KtchifyGauge( + gauge: FervidexScore["option"]["gauge"] +): DryScore<"iidx:SP" | "iidx:DP">["scoreMeta"]["gauge"] { + switch (gauge) { + case "ASSISTED_EASY": + return "ASSISTED EASY"; + case "EASY": + return "EASY"; + case "EX_HARD": + return "EX-HARD"; + case "HARD": + return "HARD"; + case null: + case undefined: + return "NORMAL"; + } +} + +function KtchifyRange( + gauge: FervidexScore["option"]["range"] +): DryScore<"iidx:SP" | "iidx:DP">["scoreMeta"]["range"] { + switch (gauge) { + case "HIDDEN_PLUS": + return "HIDDEN+"; + case "LIFT": + return "LIFT"; + case "LIFT_SUD_PLUS": + return "LIFT SUD+"; + case "SUDDEN_PLUS": + return "SUDDEN+"; + case "SUD_PLUS_HID_PLUS": + return "SUD+ HID+"; + case null: + case undefined: + return "NONE"; + } +} + +function KtchifyRandom( + gauge: FervidexScore["option"]["style"] +): DryScore<"iidx:SP" | "iidx:DP">["scoreMeta"]["random"] { + switch (gauge) { + case "RANDOM": + return "RANDOM"; + case "S_RANDOM": + return "S-RANDOM"; + case "R_RANDOM": + return "R-RANDOM"; + case "MIRROR": + return "MIRROR"; + case null: + case undefined: + return "NONRAN"; + } +} + +function SplitFervidexChartRef(ferDif: FervidexScore["chart"]) { + let playtype: Playtypes["iidx"]; + if (ferDif.startsWith("sp")) { + playtype = "SP"; + } else { + playtype = "DP"; + } + + let difficulty: Difficulties["iidx:SP" | "iidx:DP"]; + + switch (ferDif[ferDif.length - 1]) { + case "b": + difficulty = "BEGINNER"; + break; + case "n": + difficulty = "NORMAL"; + break; + case "h": + difficulty = "HYPER"; + break; + case "a": + difficulty = "ANOTHER"; + break; + case "l": + difficulty = "LEGGENDARIA"; + break; + default: + throw new InternalFailure(`Invalid fervidex difficulty of ${ferDif}`); + } + + return { playtype, difficulty }; +} + +export const ConverterIRFervidex: ConverterFunction = async ( + data, + context, + importType, + logger +) => { + let { difficulty, playtype } = SplitFervidexChartRef(data.chart); + + let chart = await FindChartOnInGameID("iidx", data.entry_id, playtype, difficulty); + + if (!chart) { + throw new KTDataNotFoundFailure( + `Could not find chart with songID ${data.entry_id} (${playtype} ${difficulty})`, + importType, + data, + context + ); + } + + let song = await FindSongOnID("iidx", chart.songID); + + if (!song) { + logger.severe(`Song ${chart.songID} (iidx) has no parent song?`); + throw new InternalFailure(`Song ${chart.songID} (iidx) has no parent song?`); + } + + let gaugeHistory = data.gauge.map((e) => (e > 200 ? null : e)); + + let gauge = gaugeHistory[gaugeHistory.length - 1]; + + if (gauge && gauge > 100) { + throw new InvalidScoreFailure(`Invalid value of gauge ${gauge}.`); + } + + const percent = GenericCalculatePercent("iidx", data.ex_score); + + if (percent > 100) { + throw new InvalidScoreFailure( + `Invalid score of ${data.ex_score} for chart ${song.title} (${playtype} ${difficulty}). Resulted in percent ${percent}.` + ); + } + + let dryScore: DryScore<"iidx:SP" | "iidx:DP"> = { + game: "iidx", + service: "fervidex", + comment: null, + importType: "ir/fervidex", + timeAchieved: Date.now(), + scoreData: { + score: data.ex_score, + percent, + grade: GetGradeFromPercent("iidx", percent) as Grades["iidx:SP" | "iidx:DP"], + lamp: LAMP_LOOKUP[data.clear_type] as Lamps["iidx:SP" | "iidx:DP"], + hitData: { + pgreat: data.pgreat, + great: data.great, + good: data.good, + bad: data.bad, + poor: data.poor, + }, + hitMeta: { + fast: data.fast, + slow: data.slow, + maxCombo: data.max_combo, + gaugeHistory, + gauge, + }, + }, + scoreMeta: { + assist: KtchifyAssist(data.option.assist), + gauge: KtchifyGauge(data.option.gauge), + random: KtchifyRandom(data.option.style), + range: KtchifyRange(data.option.range), + }, + }; + + return { song, chart, dryScore }; +}; diff --git a/server/src/score-import/import-types/ir/fervidex/parser.ts b/server/src/score-import/import-types/ir/fervidex/parser.ts new file mode 100644 index 000000000..a856c2975 --- /dev/null +++ b/server/src/score-import/import-types/ir/fervidex/parser.ts @@ -0,0 +1,62 @@ +import { EmptyObject, KtLogger, ParserFunctionReturnsSync } from "../../../../types"; +import p, { ValidSchemaValue, PrudenceSchema } from "prudence"; +import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; +import { FormatPrError, optNull } from "../../../../common/prudence"; +import { FervidexScore } from "./types"; +import { ConverterIRFervidex } from "./converter"; + +const PR_Fervidex: PrudenceSchema = { + chart: p.isIn("spb", "spn", "dpn", "sph", "dph", "spa", "dpa", "spl", "dpl"), + entry_id: p.isPositiveInteger, + + pgreat: p.isPositiveInteger, + great: p.isPositiveInteger, + good: p.isPositiveInteger, + bad: p.isPositiveInteger, + poor: p.isPositiveInteger, + + slow: p.isPositiveInteger, + fast: p.isPositiveInteger, + max_combo: p.isPositiveInteger, + + ex_score: p.isPositiveInteger, + clear_type: p.isBoundedInteger(0, 7), + + gauge: [p.isBoundedInteger(0, 255)], + + dead: { + measure: optNull(p.isPositiveInteger), + note: optNull(p.isPositiveInteger), + }, + + option: { + gauge: optNull(p.isIn("ASSISTED_EASY", "EASY", "HARD", "EX_HARD")), + range: optNull( + p.isIn("SUDDEN_PLUS", "HIDDEN_PLUS", "SUD_PLUS_HID_PLUS", "LIFT", "LIFT_SUD_PLUS") + ), + style: optNull(p.isIn("RANDOM", "R_RANDOM", "S_RANDOM", "MIRROR")), + assist: optNull(p.isIn("AUTO_SCRATCH", "LEGACY_NOTE", "ASCR_LEGACY", "FULL_ASSIST")), + }, + + // we dont use it and we dont care. + pacemaker: p.optional(p.any), +}; + +export function ParseFervidexSingle( + body: Record, + logger: KtLogger +): ParserFunctionReturnsSync { + let err = p(body, PR_Fervidex); + + if (err) { + throw new ScoreImportFatalError(400, FormatPrError(err, "Invalid Fervidex Request?")); + } + + // asserted using prudence. + return { + context: {}, + game: "iidx", + iterable: ([body] as unknown) as FervidexScore[], + ConverterFunction: ConverterIRFervidex, + }; +} diff --git a/server/src/score-import/import-types/ir/fervidex/types.ts b/server/src/score-import/import-types/ir/fervidex/types.ts new file mode 100644 index 000000000..6c65face3 --- /dev/null +++ b/server/src/score-import/import-types/ir/fervidex/types.ts @@ -0,0 +1,46 @@ +import { integer } from "kamaitachi-common"; + +export interface FervidexScore { + chart: `${"sp" | "dp"}${"b" | "n" | "h" | "a" | "l"}`; + + entry_id: integer; + + pgreat: integer; + great: integer; + good: integer; + bad: integer; + poor: integer; + + slow: integer; + fast: integer; + max_combo: integer; + + ex_score: integer; + clear_type: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; + + gauge: [integer]; + + dead: { + measure?: integer | null; + note?: integer | null; + }; + + option: { + gauge?: "ASSISTED_EASY" | "EASY" | "HARD" | "EX_HARD" | null; + range?: + | "SUDDEN_PLUS" + | "HIDDEN_PLUS" + | "SUD_PLUS_HID_PLUS" + | "LIFT" + | "LIFT_SUD_PLUS" + | null; + style?: "RANDOM" | "R_RANDOM" | "S_RANDOM" | "MIRROR"; + assist?: "AUTO_SCRATCH" | "LEGACY_NOTE" | "ASCR_LEGACY" | "FULL_ASSIST"; + }; + + pacemaker: { + name?: string | null; + score?: integer | null; + type?: string | null; // too lazy to type this and we dont use it. + }; +}