diff --git a/common/src/config/config.ts b/common/src/config/config.ts index 6964d1098..bfbd2cfb9 100644 --- a/common/src/config/config.ts +++ b/common/src/config/config.ts @@ -81,6 +81,7 @@ interface BaseGamePTConfig { clearLamp: Lamps[I]; classHumanisedFormat: Record>; + classProperties: Record; judgements: Array; @@ -356,6 +357,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { dan: IIDXDans, }, + classProperties: { + dan: { downgradable: false }, + }, supportsESD: true, judgementWindows: [ @@ -553,6 +557,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { dan: IIDXDans, }, + classProperties: { + dan: { downgradable: false }, + }, supportsESD: true, judgementWindows: [ @@ -677,6 +684,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { colour: CHUNITHMColours, }, + classProperties: { + colour: { downgradable: true }, + }, supportsESD: false, judgements: ["jcrit", "justice", "attack", "miss"], @@ -769,6 +779,10 @@ const GAME_PT_CONFIGS: GamePTConfigs = { dan: SDVXDans, vfClass: SDVXVFClasses, }, + classProperties: { + dan: { downgradable: false }, + vfClass: { downgradable: true }, + }, supportsESD: false, judgements: ["critical", "near", "miss"], @@ -855,6 +869,7 @@ const GAME_PT_CONFIGS: GamePTConfigs = { clearLamp: "CLEAR", classHumanisedFormat: {}, + classProperties: {}, supportsESD: false, judgements: ["critical", "near", "miss"], @@ -939,6 +954,7 @@ const GAME_PT_CONFIGS: GamePTConfigs = { clearLamp: "CLEAR", classHumanisedFormat: {}, + classProperties: {}, supportsESD: false, judgements: ["critical", "near", "miss"], @@ -1010,6 +1026,7 @@ const GAME_PT_CONFIGS: GamePTConfigs = { clearLamp: "CLEAR", classHumanisedFormat: {}, + classProperties: {}, supportsESD: true, judgementWindows: [ @@ -1122,6 +1139,12 @@ const GAME_PT_CONFIGS: GamePTConfigs = { lnDan: BMSLNDans, scratchDan: BMSScratchDans, }, + classProperties: { + genocideDan: { downgradable: false }, + stslDan: { downgradable: false }, + lnDan: { downgradable: false }, + scratchDan: { downgradable: false }, + }, supportsESD: false, judgements: ["pgreat", "great", "good", "bad", "poor"], @@ -1231,6 +1254,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { genocideDan: BMSGenocideDans, }, + classProperties: { + genocideDan: { downgradable: false }, + }, supportsESD: false, judgements: ["pgreat", "great", "good", "bad", "poor"], @@ -1358,6 +1384,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { dan: DDRDans, }, + classProperties: { + dan: { downgradable: false }, + }, supportsESD: true, judgementWindows: [ @@ -1487,6 +1516,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { dan: DDRDans, }, + classProperties: { + dan: { downgradable: false }, + }, supportsESD: true, judgementWindows: [ @@ -1585,6 +1617,7 @@ const GAME_PT_CONFIGS: GamePTConfigs = { clearLamp: "CLEAR", classHumanisedFormat: {}, + classProperties: {}, supportsESD: false, judgements: ["perfect", "great", "good", "miss"], @@ -1688,6 +1721,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { colour: GitadoraColours, }, + classProperties: { + colour: { downgradable: true }, + }, supportsESD: false, judgements: ["perfect", "great", "good", "ok", "miss"], @@ -1774,6 +1810,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { colour: GitadoraColours, }, + classProperties: { + colour: { downgradable: true }, + }, supportsESD: false, judgements: ["perfect", "great", "good", "ok", "miss"], @@ -1863,6 +1902,10 @@ const GAME_PT_CONFIGS: GamePTConfigs = { stageUp: WaccaStageUps, colour: WaccaColours, }, + classProperties: { + stageUp: { downgradable: false }, + colour: { downgradable: true }, + }, supportsESD: false, judgements: ["marvelous", "great", "good", "miss"], @@ -1947,6 +1990,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { class: PopnClasses, }, + classProperties: { + class: { downgradable: true }, + }, supportsESD: false, judgements: ["cool", "great", "good", "bad"], @@ -2034,6 +2080,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { colour: JubeatColours, }, + classProperties: { + colour: { downgradable: true }, + }, supportsESD: false, judgements: ["perfect", "great", "good", "poor", "miss"], @@ -2139,6 +2188,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { dan: PMSDans, }, + classProperties: { + dan: { downgradable: false }, + }, supportsESD: false, judgements: ["cool", "great", "good", "bad", "poor"], @@ -2247,6 +2299,9 @@ const GAME_PT_CONFIGS: GamePTConfigs = { classHumanisedFormat: { dan: PMSDans, }, + classProperties: { + dan: { downgradable: false }, + }, supportsESD: false, judgements: ["cool", "great", "good", "bad", "poor"], @@ -2350,6 +2405,7 @@ const GAME_PT_CONFIGS: GamePTConfigs = { clearLamp: "CLEAR", classHumanisedFormat: {}, + classProperties: {}, supportsESD: false, judgements: ["fantastic", "excellent", "great", "decent", "wayoff", "miss"], diff --git a/server/src/lib/score-import/framework/user-game-stats/builtin-class-handlers.ts b/server/src/lib/score-import/framework/user-game-stats/builtin-class-handlers.ts index 390c9f696..6e987d466 100644 --- a/server/src/lib/score-import/framework/user-game-stats/builtin-class-handlers.ts +++ b/server/src/lib/score-import/framework/user-game-stats/builtin-class-handlers.ts @@ -34,7 +34,7 @@ export function CalculateSDVXClass( export function SDVXVF6ToClass(vf: number, logger: KtLogger) { // This is impossible, but a failsafe regardless if (vf >= 24) { - logger.warn(`User has excessive VF5 of ${vf}. Defaulting to Imperial IV.`); + logger.warn(`User has excessive VF6 of ${vf}. Defaulting to Imperial IV.`); return SDVXVFClasses.IMPERIAL_IV; } else if (vf >= 20) { // imperial i -> iv has gaps of 1 diff --git a/server/src/lib/score-import/framework/user-game-stats/classes.test.ts b/server/src/lib/score-import/framework/user-game-stats/classes.test.ts index d27ce5947..c2e4c96c0 100644 --- a/server/src/lib/score-import/framework/user-game-stats/classes.test.ts +++ b/server/src/lib/score-import/framework/user-game-stats/classes.test.ts @@ -124,7 +124,7 @@ t.test("#ProcessClassDeltas", (t) => { t.end(); }); - t.test("Should not return worse classes", async (t) => { + t.test("Should not return worse classes if the class isn't downgradable", async (t) => { const res = await ProcessClassDeltas( "iidx", "SP", @@ -139,5 +139,28 @@ t.test("#ProcessClassDeltas", (t) => { t.end(); }); + t.test("Should return worse classes if the class is downgradable", async (t) => { + const res = await ProcessClassDeltas( + "sdvx", + "Single", + { vfClass: 9 }, + { classes: { vfClass: 10 } } as unknown as UserGameStats, + 1, + logger + ); + + t.strictSame(res, [ + { + game: "sdvx", + set: "vfClass", + playtype: "Single", + old: 10, + new: 9, + }, + ]); + + t.end(); + }); + t.end(); }); diff --git a/server/src/lib/score-import/framework/user-game-stats/classes.ts b/server/src/lib/score-import/framework/user-game-stats/classes.ts index 074abf869..6a9cb91a7 100644 --- a/server/src/lib/score-import/framework/user-game-stats/classes.ts +++ b/server/src/lib/score-import/framework/user-game-stats/classes.ts @@ -9,6 +9,7 @@ import { import deepmerge from "deepmerge"; import db from "external/mongo/db"; import { EmitWebhookEvent } from "lib/webhooks/webhooks"; +import { GetGamePTConfig } from "tachi-common"; import { ReturnClassIfGreater } from "utils/class"; import type { ClassHandler, ScoreClasses } from "./types"; import type { KtLogger } from "lib/logger/logger"; @@ -87,7 +88,7 @@ const STATIC_CLASS_HANDLERS: ClassHandlerMap = { * @param ratings - A users ratings. This is calculated in rating.ts, and passed via update-ugs.ts. * We request this because we need it for things like gitadora's skill divisions - We don't need to calculate our skill * statistic twice if we just request it be passed to us! - * @param ImportTypeClassResolveFn - The Custom Resolve Function that certain import types may pass to us as a means + * @param ClassHandler - The Custom Resolve Function that certain import types may pass to us as a means * for retrieving information about a class. This returns the same thing as this function, and it is merged with the * defaults. */ @@ -143,6 +144,8 @@ export async function ProcessClassDeltas( const achievementOps = []; + const gptConfig = GetGamePTConfig(game, playtype); + for (const s of Object.keys(classes)) { const classSet = s as keyof GameClasses; const classVal = classes[classSet]; @@ -152,12 +155,19 @@ export async function ProcessClassDeltas( continue; } + const classConfig = gptConfig.classProperties[classSet]; + try { const isGreater = ReturnClassIfGreater(classSet, classVal, userGameStats); - if (isGreater === false) { + // if this was worse, and this class isn't downgradable (i.e. it's a dan) + // then don't do anything + if (isGreater === false && !classConfig.downgradable) { continue; } else { + // otherwise, provide this as an update. + // This *may* be negative in the case where the user downgraded a + // downgradable class (i.e. deleted scores, chart re-rates). let delta: ClassDelta; if (isGreater === null) { @@ -178,7 +188,13 @@ export async function ProcessClassDeltas( }; } - void EmitWebhookEvent({ type: "class-update/v1", content: { userID, ...delta } }); + // if this wasn't a downgrade + if (isGreater !== false) { + void EmitWebhookEvent({ + type: "class-update/v1", + content: { userID, ...delta }, + }); + } achievementOps.push({ userID, diff --git a/server/src/lib/score-import/framework/user-game-stats/update-ugs.ts b/server/src/lib/score-import/framework/user-game-stats/update-ugs.ts index 6b0033297..b887c187c 100644 --- a/server/src/lib/score-import/framework/user-game-stats/update-ugs.ts +++ b/server/src/lib/score-import/framework/user-game-stats/update-ugs.ts @@ -10,6 +10,9 @@ import type { ClassHandler } from "./types"; import type { KtLogger } from "lib/logger/logger"; import type { ClassDelta, Game, integer, Playtype, UserGameStats } from "tachi-common"; +/** + * @param allowDowngrades - If passed, this will allow metrics to be downgraded. + */ export async function UpdateUsersGamePlaytypeStats( game: Game, playtype: Playtype, @@ -17,6 +20,8 @@ export async function UpdateUsersGamePlaytypeStats( classHandler: ClassHandler | null, logger: KtLogger ): Promise> { + logger.debug(`Calculating Ratings...`); + const ratings = await CalculateRatings(game, playtype, userID, logger); // Attempt to find a users game stats if one already exists. If one doesn't exist, diff --git a/server/src/lib/score-mutation/delete-scores.test.ts b/server/src/lib/score-mutation/delete-scores.test.ts index 9ac2a35e9..3fa32a2e5 100644 --- a/server/src/lib/score-mutation/delete-scores.test.ts +++ b/server/src/lib/score-mutation/delete-scores.test.ts @@ -1,11 +1,16 @@ import { DeleteScore } from "./delete-scores"; import deepmerge from "deepmerge"; import db from "external/mongo/db"; +import { SDVXVFClasses } from "lib/constants/classes"; +import CreateLogCtx from "lib/logger/logger"; import t from "tap"; +import { mkFakeSDVXChart, mkFakeSDVXPB } from "test-utils/misc"; import ResetDBState from "test-utils/resets"; -import { TestingIIDXSPScore } from "test-utils/test-data"; +import { TestingIIDXSPScore, TestingSDVXScore } from "test-utils/test-data"; import type { ImportDocument, ScoreDocument, SessionDocument } from "tachi-common"; +const logger = CreateLogCtx(__filename); + const mockImportDocument: ImportDocument = { userID: 1, userIntent: false, @@ -173,5 +178,78 @@ t.test("#DeleteScore", (t) => { t.end(); }); + t.test("Should update classes if the user's classes should need to change.", async (t) => { + await db.charts.sdvx.insert( + mkFakeSDVXChart("chart_1", { + difficulty: "EXH", + }) + ); + await db.charts.sdvx.insert( + mkFakeSDVXChart("chart_2", { + difficulty: "NOV", + }) + ); + + const score = deepmerge(TestingSDVXScore, { + scoreID: "scoreid_1", + scoreData: { score: 10_000_000 } as ScoreDocument["scoreData"], + chartID: "chart_1", + calculatedData: { VF6: 10 }, + }); + const score2 = deepmerge(TestingSDVXScore, { + scoreID: "scoreid_2", + scoreData: { score: 9_000_000 } as ScoreDocument["scoreData"], + chartID: "chart_2", + calculatedData: { VF6: 4 }, + }); + + await db.scores.insert([score, score2]); + await db["personal-bests"].insert([ + mkFakeSDVXPB({ + chartID: "chart_1", + calculatedData: { + VF6: 10, + }, + }), + mkFakeSDVXPB({ + chartID: "chart_2", + calculatedData: { + VF6: 4, + }, + }), + ]); + + await db["game-stats"].insert({ + userID: 1, + game: "sdvx", + playtype: "Single", + classes: { + vfClass: SDVXVFClasses.CYAN_I, + }, + ratings: { + VF6: 14, + }, + }); + + await DeleteScore(score); + + const res = await db["game-stats"].findOne({ + userID: 1, + game: "sdvx", + playtype: "Single", + }); + + t.hasStrict(res, { + classes: { + vfClass: SDVXVFClasses.SIENNA_II, + }, + ratings: { + VF6: 4, + }, + }); + + t.end(); + }); + t.end(); }); diff --git a/server/src/lib/score-mutation/delete-scores.ts b/server/src/lib/score-mutation/delete-scores.ts index 038c36f0a..9cf52013d 100644 --- a/server/src/lib/score-mutation/delete-scores.ts +++ b/server/src/lib/score-mutation/delete-scores.ts @@ -120,7 +120,7 @@ export async function DeleteScore( } export async function DeleteMultipleScores(scores: Array, blacklist = false) { - logger.info(`received request to delete ${scores.length} (Blacklist: ${blacklist}).`); + logger.info(`Received request to delete ${scores.length} (Blacklist: ${blacklist}).`); const scoreIDs = scores.map((e) => e.scoreID); diff --git a/server/src/lib/setup/config.ts b/server/src/lib/setup/config.ts index 19c44443e..49c1fcee4 100644 --- a/server/src/lib/setup/config.ts +++ b/server/src/lib/setup/config.ts @@ -4,7 +4,6 @@ import dotenv from "dotenv"; import JSON5 from "json5"; import p from "prudence"; import { StaticConfig } from "tachi-common"; -import { IsNullishOrEmptyStr } from "utils/misc"; import { FormatPrError } from "utils/prudence"; import fs from "fs"; import { URL } from "url"; @@ -264,7 +263,7 @@ if (!mongoUrl) { const seqUrl = process.env.SEQ_URL ?? ""; -if (!seqUrl && !IsNullishOrEmptyStr(tachiServerConfig.LOGGER_CONFIG.SEQ_API_KEY)) { +if (!seqUrl && tachiServerConfig.LOGGER_CONFIG.SEQ_API_KEY) { logger.warn( `No SEQ_URL specified in environment, yet LOGGER_CONFIG.SEQ_API_KEY was defined. No logs will be sent to Seq!` ); diff --git a/server/src/test-utils/misc.ts b/server/src/test-utils/misc.ts index 08f3e27f8..5a63b5043 100644 --- a/server/src/test-utils/misc.ts +++ b/server/src/test-utils/misc.ts @@ -7,10 +7,13 @@ import { HC511UserGoal, TestingIIDXSPScore, TestingIIDXSPScorePB, + TestingSDVXAlbidaChart, + TestingSDVXPB, TestingSDVXScore, } from "./test-data"; import deepmerge from "deepmerge"; import type { + ChartDocument, Game, GoalDocument, GoalSubscriptionDocument, @@ -117,3 +120,17 @@ export function mkFakeGameStats(userID: integer, modifant: Partial> = {} +) { + return dmf(TestingSDVXAlbidaChart, { + chartID, + ...modifant, + }); +} + +export function mkFakeSDVXPB(modifant: Partial> = {}) { + return dmf(TestingSDVXPB, modifant); +} diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 3cc504d4f..276b01735 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -169,6 +169,40 @@ export const TestingSDVXScore: ScoreDocument<"sdvx:Single"> = { importType: "ir/direct-manual", }; +export const TestingSDVXPB: PBScoreDocument<"sdvx:Single"> = { + game: "sdvx", + playtype: "Single", + userID: 1, + scoreData: { + score: 786, + lamp: "CLEAR", + percent: 50, + grade: "C", + esd: 30, + gradeIndex: 3, + lampIndex: 4, + judgements: {}, + hitMeta: {}, + }, + calculatedData: {}, + timeAchieved: 1619454485988, + songID: 1, + + // albida adv + chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", + highlight: false, + isPrimary: true, + composedFrom: { + lampPB: "score_1", + scorePB: "score_1", + }, + rankingData: { + outOf: 1, + rank: 1, + rivalRank: null, + }, +}; + export const TestingSDVXAlbidaChart: ChartDocument<"sdvx:Single"> = { rgcID: null, chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",