From 68ad36df985421017ce4728a9819338d31fdc4ab Mon Sep 17 00:00:00 2001 From: nairobi Date: Thu, 26 Dec 2024 21:46:02 +0100 Subject: [PATCH 1/3] fix: floating-point profile rating calculation --- .../game-implementations/games/ongeki.test.ts | 53 ++++++++++++++++++- .../src/game-implementations/games/ongeki.ts | 2 +- .../utils/profile-calc.ts | 27 ++++++++-- server/src/test-utils/test-data.ts | 35 ++++++++++++ 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/server/src/game-implementations/games/ongeki.test.ts b/server/src/game-implementations/games/ongeki.test.ts index bd04233e9..7c6ac8ad8 100644 --- a/server/src/game-implementations/games/ongeki.test.ts +++ b/server/src/game-implementations/games/ongeki.test.ts @@ -6,7 +6,7 @@ import { ONGEKI_BELL_LAMPS, ONGEKI_GRADES, ONGEKI_NOTE_LAMPS } from "tachi-commo import t from "tap"; import { dmf, mkMockPB, mkMockScore } from "test-utils/misc"; import ResetDBState from "test-utils/resets"; -import { TestingOngekiChart } from "test-utils/test-data"; +import { TestingOngekiChart, TestingOngekiScorePB } from "test-utils/test-data"; import type { ProvidedMetrics, ScoreData } from "tachi-common"; const baseMetrics: ProvidedMetrics["ongeki:Single"] = { @@ -71,7 +71,56 @@ t.test("ONGEKI Implementation", (t) => { }); t.todo("Session Calcs"); - t.todo("Profile Calcs"); + + t.test("Profile Calcs", (t) => { + t.beforeEach(ResetDBState); + + const mockPBs = async (ratings: Array) => { + const p = []; + + for (const [idx, rating] of ratings.entries()) { + p.push( + db["personal-bests"].insert({ + ...TestingOngekiScorePB, + chartID: `TEST${idx}`, + calculatedData: { + ...TestingOngekiScorePB.calculatedData, + rating, + }, + }) + ); + } + + await Promise.all(p); + }; + + t.test("Floating-point edge case", async (t) => { + await mockPBs([ + 17, 16.9, 16.7, 16.61, 16.55, 16.4, 16.4, 16.4, 16.39, 16.35, 16.33, 16.32, 16.31, + 16.3, 16.28, 16.28, 16.27, 16.27, 16.25, 16.25, 16.24, 16.24, 16.23, 16.21, 16.2, + 16.2, 16.2, 16.2, 16.18, 16.17, 16.17, 16.16, 16.15, 16.14, 16.14, 16.13, 16.11, + 16.1, 16.1, 16.06, 16.06, 16.06, 16.05, 16.05, 16.04, 16.02, 16.01, 16.01, 16, 16, + 16, 16, 15.98, 15.98, 15.98, 15.97, 15.97, 15.94, 15.94, 15.93, 15.93, 15.92, 15.92, + 15.92, 15.91, 15.91, 15.91, 15.9, 15.9, 15.9, 15.9, 15.9, 15.9, 15.9, 15.9, 15.9, + 15.88, 15.88, 15.88, 15.88, 15.87, 15.87, 15.87, 15.86, 15.86, 15.85, 15.84, 15.83, + 15.82, 15.8, 15.8, 15.8, 15.8, 15.8, 15.79, 15.79, 15.78, 15.78, 15.78, 15.77, + ]); + + t.equal(await ONGEKI_IMPL.profileCalcs.naiveRating("ongeki", "Single", 1), 16.27); + + t.end(); + }); + + t.test("Profile with fewer than 45 scores", async (t) => { + await mockPBs([16, 16, 16, 16]); + + t.equal(await ONGEKI_IMPL.profileCalcs.naiveRating("ongeki", "Single", 1), 1.42); + + t.end(); + }); + + t.end(); + }); t.test("Colour Deriver", (t) => { const f = (v: number | null, expected: any) => diff --git a/server/src/game-implementations/games/ongeki.ts b/server/src/game-implementations/games/ongeki.ts index 3f14fe5d1..e724bd53b 100644 --- a/server/src/game-implementations/games/ongeki.ts +++ b/server/src/game-implementations/games/ongeki.ts @@ -56,7 +56,7 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { }, sessionCalcs: { naiveRating: SessionAvgBest10For("rating") }, profileCalcs: { - naiveRating: ProfileAvgBestN("rating", 45), + naiveRating: ProfileAvgBestN("rating", 45, false, 100), }, classDerivers: { colour: (ratings) => { diff --git a/server/src/game-implementations/utils/profile-calc.ts b/server/src/game-implementations/utils/profile-calc.ts index 5cb840fdb..27b3d2695 100644 --- a/server/src/game-implementations/utils/profile-calc.ts +++ b/server/src/game-implementations/utils/profile-calc.ts @@ -15,6 +15,7 @@ import type { * @param n - The amount of rating values to pull. * @param returnMean - Optionally, if true, return the sum of these values divided by N. * @param nullIfNotEnoughScores - If true, return null if the total scores this user has is less than N. + * @param multiplier - If defined, ratings will be multiplied by this value and converted to integers. * * @returns - Number if the user has scores with that rating algorithm, null if they have * no scores with this rating algorithm that are non-null. @@ -23,7 +24,8 @@ function CalcN( key: ScoreRatingAlgorithms[GPT], n: integer, returnMean = false, - nullIfNotEnoughScores = false + nullIfNotEnoughScores = false, + multiplier = 1 ) { return async (game: Game, playtype: Playtype, userID: integer) => { const sc = await db["personal-bests"].find( @@ -48,6 +50,19 @@ function CalcN( return null; } + if (multiplier !== 1) { + const result = sc.reduce( + (a, e) => a + Math.round((e.calculatedData[key] ?? 0) * multiplier), + 0 + ); + + if (returnMean) { + return Math.floor(result / n) / multiplier; + } + + return result / multiplier; + } + let result = sc.reduce((a, e) => a + e.calculatedData[key]!, 0); if (returnMean) { @@ -61,17 +76,19 @@ function CalcN( export function ProfileSumBestN( key: ScoreRatingAlgorithms[GPT], n: integer, - nullIfNotEnoughScores = false + nullIfNotEnoughScores = false, + multiplier = 1 ) { - return CalcN(key, n, false, nullIfNotEnoughScores); + return CalcN(key, n, false, nullIfNotEnoughScores, multiplier); } export function ProfileAvgBestN( key: ScoreRatingAlgorithms[GPT], n: integer, - nullIfNotEnoughScores = false + nullIfNotEnoughScores = false, + multiplier = 1 ) { - return CalcN(key, n, true, nullIfNotEnoughScores); + return CalcN(key, n, true, nullIfNotEnoughScores, multiplier); } export async function GetBestRatingOnSongs( diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 1c5c8cff9..e119b4e70 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -1380,6 +1380,41 @@ export const TestingOngekiChart: ChartDocument<"ongeki:Single"> = { versions: ["brightMemory3", "brightMemory3Omni"], }; +export const TestingOngekiScorePB: PBScoreDocument<"ongeki:Single"> = { + chartID: "213796bdb6150f80ba6412ce69df1249e16c0cb0", + userID: 1, + calculatedData: { + rating: 17, + }, + composedFrom: [{ name: "Best Score", scoreID: "TESTING_SCORE_ID" }], + highlight: false, + isPrimary: true, + scoreData: { + score: 1010000, + noteLamp: "ALL BREAK", + bellLamp: "FULL BELL", + grade: "SSS+", + enumIndexes: { + grade: 11, + noteLamp: 3, + bellLamp: 1, + }, + judgements: {}, + optional: { + enumIndexes: {}, + }, + }, + rankingData: { + rank: 1, + outOf: 1, + rivalRank: null, + }, + songID: 19, + game: "ongeki", + playtype: "Single", + timeAchieved: 10000, +}; + export const TestingOngekiChartConverter: ChartDocument<"ongeki:Single"> = { chartID: "e5e4ee3d4feb233c399751b3ba3daf8ba149c9e6", data: { From b14ea09bc135fbe718c238f5f4e7d55ac1bde9b7 Mon Sep 17 00:00:00 2001 From: nairobi Date: Fri, 27 Dec 2024 03:38:16 +0100 Subject: [PATCH 2/3] fix: add chunithm and make the test clearer --- .../games/chunithm.test.ts | 40 ++++++++++++++++++- .../game-implementations/games/chunithm.ts | 2 +- .../game-implementations/games/ongeki.test.ts | 23 +++-------- server/src/test-utils/test-data.ts | 33 +++++++++++++++ 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/server/src/game-implementations/games/chunithm.test.ts b/server/src/game-implementations/games/chunithm.test.ts index 13ce66006..9731cd063 100644 --- a/server/src/game-implementations/games/chunithm.test.ts +++ b/server/src/game-implementations/games/chunithm.test.ts @@ -6,7 +6,7 @@ import { CHUNITHM_GRADES, CHUNITHM_LAMPS } from "tachi-common"; import t from "tap"; import { dmf, mkMockPB, mkMockScore } from "test-utils/misc"; import ResetDBState from "test-utils/resets"; -import { CHUNITHMBBKKChart } from "test-utils/test-data"; +import { CHUNITHMBBKKChart, TestingChunithmScorePB } from "test-utils/test-data"; import type { ProvidedMetrics, ScoreData } from "tachi-common"; const baseMetrics: ProvidedMetrics["chunithm:Single"] = { @@ -69,7 +69,43 @@ t.test("CHUNITHM Implementation", (t) => { }); t.todo("Session Calcs"); - t.todo("Profile Calcs"); + + t.test("Profile Calcs", (t) => { + t.beforeEach(ResetDBState); + + const mockPBs = async (ratings: Array) => { + await Promise.all( + ratings.map((rating, idx) => + db["personal-bests"].insert({ + ...TestingChunithmScorePB, + chartID: `TEST${idx}`, + calculatedData: { + ...TestingChunithmScorePB.calculatedData, + rating, + }, + }) + ) + ); + }; + + t.test("Floating-point edge case", async (t) => { + await mockPBs(Array(30).fill(17.15)); + + t.equal(await CHUNITHM_IMPL.profileCalcs.naiveRating("chunithm", "Single", 1), 17.15); + + t.end(); + }); + + t.test("Profile with fewer than 30 scores", async (t) => { + await mockPBs([16, 16, 16, 16]); + + t.equal(await CHUNITHM_IMPL.profileCalcs.naiveRating("chunithm", "Single", 1), 2.13); + + t.end(); + }); + + t.end(); + }); t.test("Colour Deriver", (t) => { const f = (v: number | null, expected: any) => diff --git a/server/src/game-implementations/games/chunithm.ts b/server/src/game-implementations/games/chunithm.ts index d33baf9b7..a6debed46 100644 --- a/server/src/game-implementations/games/chunithm.ts +++ b/server/src/game-implementations/games/chunithm.ts @@ -16,7 +16,7 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = { rating: (scoreData, chart) => CHUNITHMRating.calculate(scoreData.score, chart.levelNum), }, sessionCalcs: { naiveRating: SessionAvgBest10For("rating") }, - profileCalcs: { naiveRating: ProfileAvgBestN("rating", 30) }, + profileCalcs: { naiveRating: ProfileAvgBestN("rating", 30, false, 100) }, classDerivers: { colour: (ratings) => { const rating = ratings.naiveRating; diff --git a/server/src/game-implementations/games/ongeki.test.ts b/server/src/game-implementations/games/ongeki.test.ts index 7c6ac8ad8..362b14c5c 100644 --- a/server/src/game-implementations/games/ongeki.test.ts +++ b/server/src/game-implementations/games/ongeki.test.ts @@ -76,10 +76,8 @@ t.test("ONGEKI Implementation", (t) => { t.beforeEach(ResetDBState); const mockPBs = async (ratings: Array) => { - const p = []; - - for (const [idx, rating] of ratings.entries()) { - p.push( + await Promise.all( + ratings.map((rating, idx) => db["personal-bests"].insert({ ...TestingOngekiScorePB, chartID: `TEST${idx}`, @@ -88,23 +86,12 @@ t.test("ONGEKI Implementation", (t) => { rating, }, }) - ); - } - - await Promise.all(p); + ) + ); }; t.test("Floating-point edge case", async (t) => { - await mockPBs([ - 17, 16.9, 16.7, 16.61, 16.55, 16.4, 16.4, 16.4, 16.39, 16.35, 16.33, 16.32, 16.31, - 16.3, 16.28, 16.28, 16.27, 16.27, 16.25, 16.25, 16.24, 16.24, 16.23, 16.21, 16.2, - 16.2, 16.2, 16.2, 16.18, 16.17, 16.17, 16.16, 16.15, 16.14, 16.14, 16.13, 16.11, - 16.1, 16.1, 16.06, 16.06, 16.06, 16.05, 16.05, 16.04, 16.02, 16.01, 16.01, 16, 16, - 16, 16, 15.98, 15.98, 15.98, 15.97, 15.97, 15.94, 15.94, 15.93, 15.93, 15.92, 15.92, - 15.92, 15.91, 15.91, 15.91, 15.9, 15.9, 15.9, 15.9, 15.9, 15.9, 15.9, 15.9, 15.9, - 15.88, 15.88, 15.88, 15.88, 15.87, 15.87, 15.87, 15.86, 15.86, 15.85, 15.84, 15.83, - 15.82, 15.8, 15.8, 15.8, 15.8, 15.8, 15.79, 15.79, 15.78, 15.78, 15.78, 15.77, - ]); + await mockPBs(Array(45).fill(16.27)); t.equal(await ONGEKI_IMPL.profileCalcs.naiveRating("ongeki", "Single", 1), 16.27); diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index e119b4e70..e64a01f81 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -651,6 +651,39 @@ export const CHUNITHMBBKKChart: ChartDocument<"chunithm:Single"> = { versions: ["paradiselost"], }; +export const TestingChunithmScorePB: PBScoreDocument<"chunithm:Single"> = { + chartID: "192b96bdb6150f80ba6412ce02df1249e16c0cb0", + userID: 1, + calculatedData: { + rating: 5, + }, + composedFrom: [{ name: "Best Score", scoreID: "TESTING_SCORE_ID" }], + highlight: false, + isPrimary: true, + scoreData: { + score: 1010000, + lamp: "ALL JUSTICE CRITICAL", + grade: "SSS+", + enumIndexes: { + grade: 13, + lamp: 4, + }, + judgements: {}, + optional: { + enumIndexes: {}, + }, + }, + rankingData: { + rank: 1, + outOf: 1, + rivalRank: null, + }, + songID: 3, + game: "chunithm", + playtype: "Single", + timeAchieved: 10000, +}; + export const TestingDoraChart: ChartDocument<"gitadora:Dora"> = { songID: 0, chartID: "29f0bfab357ba54e3fd0176fb3cbc578c9ec8df5", From 080f189fdd6587ec0951a04366784fe844cff860 Mon Sep 17 00:00:00 2001 From: nairobi Date: Sat, 28 Dec 2024 22:57:53 +0100 Subject: [PATCH 3/3] fix: add rating formatters The implicit formatter is FloorToNDP(). Ugh. --- common/src/config/game-support/chunithm.ts | 9 +++++++-- common/src/config/game-support/ongeki.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/common/src/config/game-support/chunithm.ts b/common/src/config/game-support/chunithm.ts index 7c71154c0..b7b5636fa 100644 --- a/common/src/config/game-support/chunithm.ts +++ b/common/src/config/game-support/chunithm.ts @@ -1,6 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { FmtNum } from "../../utils/util"; -import { ClassValue, zodNonNegativeInt } from "../config-utils"; +import { ClassValue, ToDecimalPlaces, zodNonNegativeInt } from "../config-utils"; import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -87,15 +87,20 @@ export const CHUNITHM_SINGLE_CONF = { rating: { description: "The rating value of this score. This is identical to the system used in game.", + formatter: ToDecimalPlaces(2), }, }, sessionRatingAlgs: { - naiveRating: { description: "The average of your best 10 ratings this session." }, + naiveRating: { + description: "The average of your best 10 ratings this session.", + formatter: ToDecimalPlaces(2), + }, }, profileRatingAlgs: { naiveRating: { description: "The average of your best 30 ratings. This is different to in-game, as it does not take into account your recent scores in any way.", + formatter: ToDecimalPlaces(2), }, }, diff --git a/common/src/config/game-support/ongeki.ts b/common/src/config/game-support/ongeki.ts index d7231df23..9fbf753ee 100644 --- a/common/src/config/game-support/ongeki.ts +++ b/common/src/config/game-support/ongeki.ts @@ -1,6 +1,6 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; import { FmtNum } from "../../utils/util"; -import { ClassValue } from "../config-utils"; +import { ClassValue, ToDecimalPlaces } from "../config-utils"; import { p } from "prudence"; import { z } from "zod"; import type { INTERNAL_GAME_CONFIG, INTERNAL_GAME_PT_CONFIG } from "../../types/internals"; @@ -112,14 +112,19 @@ export const ONGEKI_SINGLE_CONF = { rating: { description: "The rating value of this score. This is identical to the system used in game.", + formatter: ToDecimalPlaces(2), }, }, sessionRatingAlgs: { - naiveRating: { description: "The average of your best 10 ratings this session." }, + naiveRating: { + description: "The average of your best 10 ratings this session.", + formatter: ToDecimalPlaces(2), + }, }, profileRatingAlgs: { naiveRating: { description: "The average of your best 45 scores.", + formatter: ToDecimalPlaces(2), }, },