diff --git a/common/src/types.ts b/common/src/types.ts index f2638b558..549c2f740 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -1256,6 +1256,12 @@ export interface PBScoreDocument extends MongoD rankingData: { rank: integer; outOf: integer; + + // out of their rivals, what is their position on this chart? + // note that we don't need to store rivalOutOf, as it's pretty much a constant + // that can just be read from the UGPT settings. + // null if the user has no rivals. + rivalRank: integer | null; }; userID: integer; chartID: string; diff --git a/server/src/lib/rivals/rivals.ts b/server/src/lib/rivals/rivals.ts index cece54034..0fe1a3df2 100644 --- a/server/src/lib/rivals/rivals.ts +++ b/server/src/lib/rivals/rivals.ts @@ -48,6 +48,36 @@ export async function GetRivalUsers(userID: integer, game: Game, playtype: Playt return rivals; } +/** + * Retrieve *all* rival IDs for people on this game. Used to recalculate rival movements on charts, + * since that is stored and cached. + */ +export async function GetEveryonesRivalIDs( + game: Game, + playtype: Playtype +): Promise>> { + const allGameSettings = await db["game-settings"].find( + { + game, + playtype, + }, + { + projection: { + userID: 1, + rivals: 1, + }, + } + ); + + const lookupTable: Record> = {}; + + for (const d of allGameSettings) { + lookupTable[d.userID] = d.rivals; + } + + return lookupTable; +} + /** * Sets an array of userIDs to be this user's rivals. Performs validation on all of the * rivals being players of the game, and not being duplicates. The maximum amount of rivals diff --git a/server/src/lib/score-import/framework/common/types.ts b/server/src/lib/score-import/framework/common/types.ts index cd722594d..d21248bd8 100644 --- a/server/src/lib/score-import/framework/common/types.ts +++ b/server/src/lib/score-import/framework/common/types.ts @@ -12,3 +12,4 @@ export type DryScore = Pick< }; export type ScorePlaytypeMap = Partial>>; +export type ChartIDPlaytypeMap = Partial>>; diff --git a/server/src/lib/score-import/framework/pb/create-pb-doc.ts b/server/src/lib/score-import/framework/pb/create-pb-doc.ts index 8ca2643cd..eb25ad973 100644 --- a/server/src/lib/score-import/framework/pb/create-pb-doc.ts +++ b/server/src/lib/score-import/framework/pb/create-pb-doc.ts @@ -6,9 +6,10 @@ import { USCMergeFn, } from "./game-specific-merge"; import db from "external/mongo/db"; +import { GetEveryonesRivalIDs, GetRivalIDs, GetRivalUsers } from "lib/rivals/rivals"; import type { KtLogger } from "lib/logger/logger"; import type { BulkWriteUpdateOneOperation } from "mongodb"; -import type { Game, integer, PBScoreDocument, ScoreDocument } from "tachi-common"; +import type { Game, integer, PBScoreDocument, Playtype, ScoreDocument } from "tachi-common"; export type PBScoreDocumentNoRank = Omit; @@ -64,9 +65,9 @@ export async function CreatePBDoc(userID: integer, chartID: string, logger: KtLo } /** - * Updates user's rankings on a given chart. + * Updates rankings on a given chart. */ -export async function UpdateChartRanking(chartID: string) { +export async function UpdateChartRanking(game: Game, playtype: Playtype, chartID: string) { const scores = await db["personal-bests"].find( { chartID }, { @@ -77,13 +78,26 @@ export async function UpdateChartRanking(chartID: string) { } ); + const allRivals = await GetEveryonesRivalIDs(game, playtype); + const bwrite: Array> = []; let rank = 0; + // what users have we saw so far? used for rivalRanking calculations + const seenUserIDs: Array = []; + for (const score of scores) { rank++; + const thisUsersRivals = allRivals[score.userID]; + + let rivalRank: integer | null = null; + + if (thisUsersRivals) { + rivalRank = thisUsersRivals.filter((e) => seenUserIDs.includes(e)).length + 1; + } + bwrite.push({ updateOne: { filter: { chartID: score.chartID, userID: score.userID }, @@ -92,6 +106,7 @@ export async function UpdateChartRanking(chartID: string) { rankingData: { rank, outOf: scores.length, + rivalRank, }, }, }, diff --git a/server/src/lib/score-import/framework/pb/process-pbs.test.ts b/server/src/lib/score-import/framework/pb/process-pbs.test.ts index 2167591f7..78362e253 100644 --- a/server/src/lib/score-import/framework/pb/process-pbs.test.ts +++ b/server/src/lib/score-import/framework/pb/process-pbs.test.ts @@ -16,7 +16,7 @@ t.test("#ProcessPBs", (t) => { await db["personal-bests"].remove({}); // scores on 511 SPA are pre-loaded into the database - await ProcessPBs(1, new Set([Testing511SPA.chartID]), logger); + await ProcessPBs("iidx", "SP", 1, new Set([Testing511SPA.chartID]), logger); const pbs = await db["personal-bests"].find({}); @@ -48,7 +48,13 @@ t.test("#ProcessPBs", (t) => { }), ]); - await ProcessPBs(1, new Set([Testing511SPA.chartID, "test1", "test2", "test3"]), logger); + await ProcessPBs( + "iidx", + "SP", + 1, + new Set([Testing511SPA.chartID, "test1", "test2", "test3"]), + logger + ); const pbs = await db["personal-bests"].find({}); diff --git a/server/src/lib/score-import/framework/pb/process-pbs.ts b/server/src/lib/score-import/framework/pb/process-pbs.ts index 2a51addb7..22634f341 100644 --- a/server/src/lib/score-import/framework/pb/process-pbs.ts +++ b/server/src/lib/score-import/framework/pb/process-pbs.ts @@ -2,12 +2,14 @@ import { CreatePBDoc, UpdateChartRanking } from "./create-pb-doc"; import db from "external/mongo/db"; import type { PBScoreDocumentNoRank } from "./create-pb-doc"; import type { KtLogger } from "lib/logger/logger"; -import type { integer } from "tachi-common"; +import type { Game, integer, Playtype } from "tachi-common"; /** * Process, recalculate and update a users PBs for this set of chartIDs. */ export async function ProcessPBs( + game: Game, + playtype: Playtype, userID: integer, chartIDs: Set, logger: KtLogger @@ -58,7 +60,7 @@ export async function ProcessPBs( // now that everything has been updated or inserted, we can refresh // the chart rankings. - await Promise.all(pbDocs.map((e) => UpdateChartRanking(e.chartID))); + await Promise.all(pbDocs.map((e) => UpdateChartRanking(game, playtype, e.chartID))); // and we're done! } diff --git a/server/src/lib/score-import/framework/score-importing/score-import-main.ts b/server/src/lib/score-import/framework/score-importing/score-import-main.ts index 819e3a835..4ce9d0711 100644 --- a/server/src/lib/score-import/framework/score-importing/score-import-main.ts +++ b/server/src/lib/score-import/framework/score-importing/score-import-main.ts @@ -14,7 +14,7 @@ import { GetGameConfig } from "tachi-common"; import { GetMillisecondsSince } from "utils/misc"; import { GetUserWithID } from "utils/user"; import type { ConverterFunction, ImportInputParser } from "../../import-types/common/types"; -import type { ScorePlaytypeMap } from "../common/types"; +import type { ChartIDPlaytypeMap, ScorePlaytypeMap } from "../common/types"; import type { ClassHandler } from "../user-game-stats/types"; import type { KtLogger } from "lib/logger/logger"; import type { ScoreImportJob } from "lib/score-import/worker/types"; @@ -292,21 +292,33 @@ export async function HandlePostImportSteps( void SetJobProgress(job, "Processing scores and updating PBs."); + const playtypes = Object.keys(scorePlaytypeMap) as Array; + // --- 5. PersonalBests --- // We want to keep an updated reference of a users best score on a given chart. // This function also handles conjoining different scores together (such as unioning best lamp and // best score). const pbTimeStart = process.hrtime.bigint(); - await ProcessPBs(user.id, chartIDs, logger); + // processing PBs is a playtype-specific action. As such, we need to split chartIDs + // accordingly + const chartIDsSeparatedByPlaytype: ChartIDPlaytypeMap = {}; + + for (const [playtype, scores] of Object.entries(scorePlaytypeMap)) { + chartIDsSeparatedByPlaytype[playtype as Playtype] = new Set(scores.map((e) => e.chartID)); + } + + await Promise.all( + Object.entries(chartIDsSeparatedByPlaytype).map(([playtype, chartIDs]) => + ProcessPBs(game, playtype as Playtype, user.id, chartIDs, logger) + ) + ); const pbTime = GetMillisecondsSince(pbTimeStart); const pbTimeRel = pbTime / chartIDs.size; logger.debug(`PB Processing took ${pbTime} milliseconds (${pbTimeRel}ms/doc)`); - const playtypes = Object.keys(scorePlaytypeMap) as Array; - void SetJobProgress(job, "Updating profile statistics."); // --- 6. Game Stats --- @@ -413,7 +425,8 @@ function ParseImportInfo(importInfo: Array) { chartIDs.add(info.content.score.chartID); if (scorePlaytypeMap[info.content.score.playtype]) { - scorePlaytypeMap[info.content.score.playtype]!.push(info.content.score); + // @ts-expect-error obviously delusional typescript moment + scorePlaytypeMap[info.content.score.playtype].push(info.content.score); } else { scorePlaytypeMap[info.content.score.playtype] = [info.content.score]; } diff --git a/server/src/lib/score-mutation/delete-scores.ts b/server/src/lib/score-mutation/delete-scores.ts index cf19eed33..038c36f0a 100644 --- a/server/src/lib/score-mutation/delete-scores.ts +++ b/server/src/lib/score-mutation/delete-scores.ts @@ -84,14 +84,20 @@ export async function DeleteScore( }); if (userHasOtherScores && attemptPBReprocess) { - await ProcessPBs(score.userID, new Set([score.chartID]), logger); + await ProcessPBs( + score.game, + score.playtype, + score.userID, + new Set([score.chartID]), + logger + ); } else { await db["personal-bests"].remove({ userID: score.userID, chartID: score.chartID, }); - await UpdateChartRanking(score.chartID); + await UpdateChartRanking(score.game, score.playtype, score.chartID); } await UpdateUsersGamePlaytypeStats(score.game, score.playtype, score.userID, null, logger); @@ -186,14 +192,20 @@ export async function DeleteMultipleScores(scores: Array, blackli }); if (userHasOtherScores) { - await ProcessPBs(score.userID, new Set([score.chartID]), logger); + await ProcessPBs( + score.game, + score.playtype, + score.userID, + new Set([score.chartID]), + logger + ); } else { await db["personal-bests"].remove({ userID: score.userID, chartID: score.chartID, }); - await UpdateChartRanking(score.chartID); + await UpdateChartRanking(score.game, score.playtype, score.chartID); } if (blacklist) { diff --git a/server/src/lib/score-mutation/update-score.ts b/server/src/lib/score-mutation/update-score.ts index 1ac2c65e9..9ba64fb75 100644 --- a/server/src/lib/score-mutation/update-score.ts +++ b/server/src/lib/score-mutation/update-score.ts @@ -64,6 +64,7 @@ export default async function UpdateScore( logger.verbose("Received Update Score request."); + // eslint-disable-next-line require-atomic-updates newScore.calculatedData = await CreateCalculatedData( newScore, chart, @@ -148,13 +149,13 @@ export default async function UpdateScore( chartID: newScore.chartID, }); - await UpdateChartRanking(newScore.chartID); + await UpdateChartRanking(newScore.game, newScore.playtype, newScore.chartID); if (updateOldChart) { await UpdateAllPBs([userID], { chartID: oldScore.chartID, }); - await UpdateChartRanking(oldScore.chartID); + await UpdateChartRanking(oldScore.game, oldScore.playtype, oldScore.chartID); } const imports = await db.imports.find({ diff --git a/server/src/server/router/ir/usc/_playtype/router.test.ts b/server/src/server/router/ir/usc/_playtype/router.test.ts index 955e22ba4..ae6e529a8 100644 --- a/server/src/server/router/ir/usc/_playtype/router.test.ts +++ b/server/src/server/router/ir/usc/_playtype/router.test.ts @@ -132,6 +132,7 @@ const USC_SCORE_PB: PBScoreDocument = { rankingData: { rank: 1, outOf: 2, + rivalRank: null, }, songID: 1, userID: 1, diff --git a/server/src/server/router/ir/usc/_playtype/usc.test.ts b/server/src/server/router/ir/usc/_playtype/usc.test.ts index 6a716cdd6..970b9764b 100644 --- a/server/src/server/router/ir/usc/_playtype/usc.test.ts +++ b/server/src/server/router/ir/usc/_playtype/usc.test.ts @@ -26,6 +26,7 @@ const mockScorePB: PBScoreDocument<"usc:Controller" | "usc:Keyboard"> = { rankingData: { outOf: 2, rank: 1, + rivalRank: null, }, scoreData: { esd: null, diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 96e8b50b9..f23171277 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -95,6 +95,7 @@ export const TestingIIDXSPScorePB: PBScoreDocument<"iidx:SP"> = { rankingData: { rank: 1, outOf: 2, + rivalRank: null, }, songID: 1, game: "iidx", diff --git a/server/src/utils/calculations/recalc-scores.ts b/server/src/utils/calculations/recalc-scores.ts index 3a5dac342..c284d0caf 100644 --- a/server/src/utils/calculations/recalc-scores.ts +++ b/server/src/utils/calculations/recalc-scores.ts @@ -7,6 +7,8 @@ import { GetAndUpdateUsersGoals } from "lib/score-import/framework/goals/goals"; import { UpdateUsersMilestones } from "lib/score-import/framework/milestones/milestones"; import { ProcessPBs } from "lib/score-import/framework/pb/process-pbs"; import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/user-game-stats/update-ugs"; +import { TachiConfig } from "lib/setup/config"; +import { GetGameConfig } from "tachi-common"; import { EfficientDBIterate } from "utils/efficient-db-iterate"; import { FormatUserDoc } from "utils/user"; import type { Game, integer, Playtype, PublicUserDocument } from "tachi-common"; @@ -95,12 +97,27 @@ export async function UpdateAllPBs(userIDs?: Array, filter = {}) { for (const user of allUsers) { logger.verbose(`Finding ${FormatUserDoc(user)}'s scores.`); - const scores = await db.scores.find(deepmerge({ userID: user.id }, filter), { - projection: { chartID: 1 }, - }); + for (const game of TachiConfig.GAMES) { + const gameConfig = GetGameConfig(game); - logger.verbose(`PBing ${FormatUserDoc(user)}'s scores.`); - await ProcessPBs(user.id, new Set(scores.map((e) => e.chartID)), logger); + for (const playtype of gameConfig.validPlaytypes) { + const scores = await db.scores.find( + deepmerge({ userID: user.id, game, playtype }, filter), + { + projection: { chartID: 1 }, + } + ); + + logger.verbose(`PBing ${FormatUserDoc(user)}'s scores.`); + await ProcessPBs( + game, + playtype, + user.id, + new Set(scores.map((e) => e.chartID)), + logger + ); + } + } } logger.verbose(`Done!`);