diff --git a/client/src/components/charts/GekichumaiScoreChart.tsx b/client/src/components/charts/GekichumaiScoreChart.tsx index d52f33c56..96e2c4feb 100644 --- a/client/src/components/charts/GekichumaiScoreChart.tsx +++ b/client/src/components/charts/GekichumaiScoreChart.tsx @@ -107,33 +107,53 @@ const strokeColor = ( return `hsl(0, 0%, ${isLight ? 35 : 67}%)`; }; -const limitScoreGraph = (game: Game, data: Serie[]) => { +const limitScoreGraph = (game: Game, data: Serie[], minval: number) => { for (const val of data[0].data) { if (typeof val.y !== "number") { break; } - if (game === "ongeki" && val.y < 970_000) { - // 969999 will be used to represent values below S + if (val.y < minval) { + // minval - 0.001 will be used to represent values below S // Without this, the line would cross the bottom axis // which looks very bad - val.y = 969_999; - } - if (game === "chunithm" && val.y < 990_000) { - val.y = 989_999; - } - if (game === "maimaidx" && val.y < 97) { - val.y = 96.9999; + val.y = minval - 0.001; } } return data; }; -const bellFloor = (data: Datum[], totalBellCount: number) => { +const scaleFloor = (data: Datum[], maximum: number | undefined) => { + if (maximum === undefined) { + return 0; + } // Scale the chart so that it drops by 3/4, unless it's a flatline const lowestValue = Number(data.filter((v) => v.y !== null).pop()?.y ?? 0); - return lowestValue === 0 - ? -totalBellCount - : clamp(-totalBellCount, Math.floor(lowestValue * 1.333), -1); + return lowestValue === 0 ? -maximum : clamp(-maximum, Math.floor(lowestValue * 1.333), -1); +}; + +const platinumTooltip = (d: Datum, starValues: number[]) => { + if (d.point.data.y < starValues[0]) { + return ( + <> + {"< 1★"} @ {formatTime(d.point.data.x)} + + ); + } + for (let i = 1; i < 6; ++i) { + if (d.point.data.y < starValues[i]) { + return ( + <> + MAX{d.point.data.y} ({i < 5 ? i + 1 : "虹"} + {"★"}-{starValues[i] - d.point.data.y}) @ {formatTime(d.point.data.x)} + + ); + } + } + return ( + <> + MAX{d.point.data.y === 0 ? "" : d.point.data.y} @ {formatTime(d.point.data.x)} + + ); }; export default function GekichumaiScoreChart({ @@ -143,7 +163,7 @@ export default function GekichumaiScoreChart({ mobileWidth = width, type, difficulty, - totalBellCount, + maximumAbsoluteValue, data, game, duration, @@ -152,12 +172,12 @@ export default function GekichumaiScoreChart({ mobileWidth?: number | string; width?: number | string; height?: number | string; - type: "Score" | "Bells" | "Life"; + type: "Score" | "Platinum" | "Bells" | "Life"; difficulty: | Difficulties["ongeki:Single"] | Difficulties["chunithm:Single"] | Difficulties["maimaidx:Single"]; - totalBellCount?: number; + maximumAbsoluteValue?: number; data: Serie[]; game: "chunithm" | "ongeki" | "maimaidx"; duration: number; @@ -183,12 +203,16 @@ export default function GekichumaiScoreChart({ } } else if (type === "Bells") { color = COLOUR_SET.vibrantYellow; - } else { + } else if (type === "Life") { color = COLOUR_SET.vibrantGreen; + } else { + color = COLOUR_SET.white; } const gradientId = type === "Score" ? difficulty : type; + data.length = Math.min(data.length, duration + 1); + const commonProps: Omit = { margin: { top: 30, bottom: 50, left: 50, right: 50 }, enableGridX: false, @@ -224,21 +248,23 @@ export default function GekichumaiScoreChart({ component = ( -,.0f"} axisLeft={{ - tickValues: [970000, 990000, 1000000, 1007500, 1010000], + tickValues: [970_000, 990_000, 1000_000, 1007_500, 1010_000], format: getScoreYAxisNotch(game), }} - gridYValues={[970000, 980000, 990000, 1000000, 1007500, 1010000]} + gridYValues={[970_000, 980_000, 990_000, 1000_000, 1007_500, 1010_000]} enableGridY={true} colors={strokeColor(difficulty)} areaBaselineValue={970000} tooltip={(d: PointTooltipProps) => ( - {d.point.data.y === 969999 ? "< 970,000 " : d.point.data.yFormatted}@{" "} - {formatTime(d.point.data.x)} + {typeof d.point.data.y === "number" && d.point.data.y < 970_000 + ? "< 970,000 " + : d.point.data.yFormatted} + @ {formatTime(d.point.data.x)} )} /> @@ -247,7 +273,7 @@ export default function GekichumaiScoreChart({ component = ( -,.0f"} axisLeft={{ @@ -260,8 +286,10 @@ export default function GekichumaiScoreChart({ areaBaselineValue={990000} tooltip={(d: PointTooltipProps) => ( - {d.point.data.y === 989_999 ? "< 990,000 " : d.point.data.yFormatted}@{" "} - {formatTime(d.point.data.x)} + {typeof d.point.data.y === "number" && d.point.data.y < 990_000 + ? "< 990,000 " + : d.point.data.yFormatted} + @ {formatTime(d.point.data.x)} )} /> @@ -270,7 +298,7 @@ export default function GekichumaiScoreChart({ component = ( -,.4f"} axisLeft={{ @@ -283,7 +311,9 @@ export default function GekichumaiScoreChart({ areaBaselineValue={97} tooltip={(d: PointTooltipProps) => ( - {d.point.data.y === 96.9999 ? "< 97% " : `${d.point.data.yFormatted}% `} + {typeof d.point.data.y === "number" && d.point.data.y < 97 + ? "< 97% " + : `${d.point.data.yFormatted}% `} @ {formatTime(d.point.data.x)} )} @@ -297,14 +327,14 @@ export default function GekichumaiScoreChart({ data={data} yScale={{ type: "linear", - min: bellFloor(data[0].data, totalBellCount!), + min: scaleFloor(data[0].data, maximumAbsoluteValue), max: 0, stacked: false, }} enableGridY={false} axisLeft={{ format: (e: number) => Math.floor(e) === e && e }} colors={strokeColor("BELLS")} - areaBaselineValue={bellFloor(data[0].data, totalBellCount!)} + areaBaselineValue={scaleFloor(data[0].data, maximumAbsoluteValue)} tooltip={(d: PointTooltipProps) => ( MAX{d.point.data.y === 0 ? "" : d.point.data.y} @{" "} @@ -333,6 +363,45 @@ export default function GekichumaiScoreChart({ )} /> ); + } else if (type === "Platinum") { + const mav = maximumAbsoluteValue ?? 0; // shorthand + const starValues = [ + Math.floor(mav * 0.94) - mav, + Math.floor(mav * 0.95) - mav, + Math.floor(mav * 0.96) - mav, + Math.floor(mav * 0.97) - mav, + Math.floor(mav * 0.98) - mav, + Math.floor(mav * 0.99) - mav, + ]; + component = ( + -,.0f"} + axisLeft={{ + tickValues: starValues, + format: (count: number) => { + if (count === starValues[5]) { + return "虹★"; + } + for (let i = 0; i < 5; ++i) { + if (count === starValues[i]) { + return `${i + 1}★`; + } + } + return "Error"; + }, + }} + gridYValues={starValues} + enableGridY={true} + colors={strokeColor("LUNATIC")} + areaBaselineValue={starValues[0]} + tooltip={(d: PointTooltipProps) => ( + {platinumTooltip(d, starValues)} + )} + /> + ); } return ( diff --git a/client/src/components/gpt-utils/GPTUtils.tsx b/client/src/components/gpt-utils/GPTUtils.tsx index 1363aeb66..b0959dca8 100644 --- a/client/src/components/gpt-utils/GPTUtils.tsx +++ b/client/src/components/gpt-utils/GPTUtils.tsx @@ -4,7 +4,10 @@ import { BMSCustomTablesTool } from "./tools/BMSCustomTablesTool"; import { IIDXEamusementExportTool } from "./tools/IIDXEamusementExportTool"; import { IIDXPlaylistsTool } from "./tools/IIDXPlaylistsTool"; import { JubilityBreakdownInsight } from "./insights/JubilityBreakdownInsight"; -import { ONGEKIBreakdownInsight } from "./insights/ONGEKIBreakdownInsight"; +import { + ONGEKIClassicBreakdownInsight, + ONGEKIRefreshBreakdownInsight, +} from "./insights/ONGEKIBreakdownInsight"; import { BMSSieglindeInfoTool } from "./tools/BMSSieglindeInfoTool"; // What utils does each game support? @@ -29,7 +32,7 @@ const GPT_UTILS: Record> = { "wacca:Single": [], "maimaidx:Single": [], "maimai:Single": [], - "ongeki:Single": [ONGEKIBreakdownInsight], + "ongeki:Single": [ONGEKIRefreshBreakdownInsight, ONGEKIClassicBreakdownInsight], "ddr:SP": [], "ddr:DP": [], }; diff --git a/client/src/components/gpt-utils/insights/ONGEKIBreakdownInsight.tsx b/client/src/components/gpt-utils/insights/ONGEKIBreakdownInsight.tsx index 160c9f2b3..fa6c569b0 100644 --- a/client/src/components/gpt-utils/insights/ONGEKIBreakdownInsight.tsx +++ b/client/src/components/gpt-utils/insights/ONGEKIBreakdownInsight.tsx @@ -9,13 +9,15 @@ import useApiQuery from "components/util/query/useApiQuery"; import React from "react"; import { Col, Row } from "react-bootstrap"; import { + AnyScoreRatingAlg, ChartDocument, COLOUR_SET, CreateSongMap, + FmtNum, Game, + GPTString, integer, PBScoreDocument, - Playtype, SongDocument, } from "tachi-common"; import { UGPT } from "types/react"; @@ -24,8 +26,12 @@ import { GPTUtility } from "types/ugpt"; import DifficultyCell from "components/tables/cells/DifficultyCell"; import TachiTable from "components/tables/components/TachiTable"; import GentleLink from "components/util/GentleLink"; +import Divider from "components/util/Divider"; +import { StarField } from "components/tables/cells/OngekiPlatinumCell"; +import { GPT_CLIENT_IMPLEMENTATIONS } from "lib/game-implementations"; +import OngekiScoreRatingCell from "components/tables/cells/OngekiScoreRatingCell"; -function Component({ game, playtype, reqUser }: UGPT) { +function ComponentClassic({ game, playtype, reqUser }: UGPT) { if (game !== "ongeki" || playtype !== "Single") { return <>; } @@ -43,54 +49,20 @@ function Component({ game, playtype, reqUser }: UGPT) { return ; } - const songMap = CreateSongMap<"ongeki">(data.songs); - const chartMap = CreateChartMap<"ongeki:Single">(data.charts); + const flatDataset: PBDataset<"ongeki:Single"> = CreateFlatDataset(data, "rating", 45); + const compoundDataset = ColumnMerge<"ongeki:Single">(flatDataset, 3); - const flatDataset: PBDataset<"ongeki:Single"> = []; - const compoundDataset: PBDataset<"ongeki:Single">[] = [[]]; - - const sortedRatings = data.pbs - .map((e) => e.calculatedData.rating) - .sort(NumericSOV((x) => x ?? -Infinity, true)); - - for (const pb of data.pbs.slice(0, 45)) { - const song = songMap.get(pb.songID); - const chart = chartMap.get(pb.chartID); - - if (song === undefined || chart === undefined) { - continue; - } - - flatDataset.push({ - ...pb, - __related: { - chart, - song, - index: sortedRatings.indexOf(pb.calculatedData.rating), - }, - }); - } - - for (const d of flatDataset) { - const back = compoundDataset[compoundDataset.length - 1]; - back.push(d); - if (back.length === 3) { - compoundDataset.push([]); - } - } + const classicRating = + Math.floor( + flatDataset.reduce((a, e) => a + Math.round((e.calculatedData.rating ?? 0) * 100), 0) / + flatDataset.length + ) / 100; return ( a + (e.calculatedData.rating ?? 0) * 100, - 0 - ) / flatDataset.length - ) / 100 - ).toFixed(2)})`} + header={`${reqUser.username}'s NaiveRatingClassic: ${classicRating.toFixed(2)}`} > ( - + FormatMillions(pb.scoreData.score)} + ratingField={(pb) => pb.calculatedData.rating?.toFixed(2) ?? "0.00"} + lampField={(pb) => ( + + )} + /> + )} + /> + + + + ); +} + +function ComponentRefresh({ game, playtype, reqUser }: UGPT) { + if (game !== "ongeki" || playtype !== "Single") { + return <>; + } + const query1 = useApiQuery<{ + pbs: Array>; + songs: Array>; + charts: Array>; + }>(`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=scoreRating`); + + const query2 = useApiQuery<{ + pbs: Array>; + songs: Array>; + charts: Array>; + }>(`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=starRating`); + + if (query1.error) { + return ; + } + + if (query2.error) { + return ; + } + + if (!query1.data || !query2.data) { + return ; + } + + const flatDatasetScore: PBDataset<"ongeki:Single"> = CreateFlatDataset( + query1.data, + "scoreRating", + 60 + ); + const flatDatasetStar: PBDataset<"ongeki:Single"> = CreateFlatDataset( + query2.data, + "starRating", + 51 + ); + + const datasetScore = ColumnMerge<"ongeki:Single">(flatDatasetScore, 3); + const datasetStar = ColumnMerge<"ongeki:Single">(flatDatasetStar, 3); + + const scoreR1k = Math.floor( + flatDatasetScore.reduce( + (a, e) => a + Math.round((e.calculatedData.scoreRating ?? 0) * 1000), + 0 + ) / 60 + ); + const starR1k = Math.floor( + flatDatasetStar + .slice(0, 50) + .reduce((a, e) => a + Math.round((e.calculatedData.starRating ?? 0) * 1000), 0) / 50 + ); + const finalRating = ((Math.floor(scoreR1k * 1.2) + starR1k) / 1000.0).toFixed(3); + + return ( + + + + ( + FormatMillions(pb.scoreData.score)} + ratingField={(pb) => } + lampField={(pb) => ( + + )} + /> + )} + /> + + ( + + `${FmtNum(pb.scoreData.platinumScore)}/${FmtNum( + chart.data.maxPlatScore + )}` + } + ratingField={(pb) => + pb.calculatedData.starRating?.toFixed(3) ?? "0.000" + } + lampField={(pb) => ( + <> + + + )} + /> )} /> @@ -112,21 +220,42 @@ function Component({ game, playtype, reqUser }: UGPT) { function CompactRow({ pbs, game, - playtype, + count, + scoreField, + ratingField, + lampField, }: { pbs: PBDataset<"ongeki:Single">[0][]; game: Game; - playtype: Playtype; + count?: number; + scoreField: ( + pb: PBScoreDocument<"ongeki:Single">, + chart: ChartDocument<"ongeki:Single"> + ) => JSX.Element | string; + ratingField: (pb: PBScoreDocument<"ongeki:Single">) => JSX.Element | string; + lampField: (pb: PBScoreDocument<"ongeki:Single">) => JSX.Element | string; }) { return ( {pbs.map((pb) => { + const index = pb.__related.index; const chart = pb.__related.chart; + + if (count !== undefined && index > count) { + return <>; + } return ( <> - + - + ); })} @@ -138,19 +267,30 @@ function CompactCell({ pb, chart, song, + scoreField, + ratingField, + lampField, }: { pb: PBScoreDocument<"ongeki:Single">; chart: ChartDocument<"ongeki:Single">; - song: SongDocument<"ongeki">; + song: SongDocument; + scoreField: ( + pb: PBScoreDocument<"ongeki:Single">, + chart: ChartDocument<"ongeki:Single"> + ) => JSX.Element | string; + ratingField: (pb: PBScoreDocument<"ongeki:Single">) => JSX.Element | string; + lampField: (pb: PBScoreDocument<"ongeki:Single">) => JSX.Element | string; }) { // Third-party scripts may find this useful const className = `c-${chart.data.inGameID}`; return ( -
+
- {song.title} + + {song.title} +
- {FormatMillions(pb.scoreData.score)} - {pb.calculatedData.rating?.toFixed(2)} - {ShortLamp(pb.scoreData.noteLamp, pb.scoreData.bellLamp)} + {scoreField(pb, chart)} + {ratingField(pb)} + {lampField(pb)}
@@ -184,26 +324,123 @@ function IndexCellCustom({ index }: { index: integer }) { ); } -function ShortLamp(noteLamp: string, bellLamp: string) { - let res = ""; +function ShortLamp({ + noteLamp, + bellLamp, + grade, +}: { + noteLamp: string; + bellLamp: string; + grade: "D" | "C" | "B" | "A" | "AA" | "AAA" | "S" | "SS" | "BB" | "BBB" | "SSS" | "SSS+"; +}) { + let color1 = COLOUR_SET.gray; + let text1 = ""; + let color2 = COLOUR_SET.gray; + let text2 = ""; + const color3 = GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].enumColours.grade[grade]; + if (noteLamp === "ALL BREAK+") { - return "ABFB+"; - } - if (noteLamp === "ALL BREAK") { - res = "AB"; + color1 = COLOUR_SET.vibrantBlue; + text1 = "AB+"; + } else if (noteLamp === "ALL BREAK") { + color1 = COLOUR_SET.white; + text1 = "AB"; } else if (noteLamp === "FULL COMBO") { - res = "FC"; + color1 = COLOUR_SET.gold; + text1 = "FC"; } + if (bellLamp === "FULL BELL") { - res += "FB"; + color2 = COLOUR_SET.gold; + text2 = "FB"; } - return res; + + const style = { + width: "2em", + height: "2em", + fontSize: "90%", + paddingTop: "3px", + }; + + return ( + + + {text1} + + + {text2} + + + {grade} + + + ); } -export const ONGEKIBreakdownInsight: GPTUtility = { +function CreateFlatDataset(data: any, alg: AnyScoreRatingAlg, count: number) { + const flatDataset: PBDataset = []; + + const songMap = CreateSongMap(data.songs); + const chartMap = CreateChartMap(data.charts); + + const sortedRatings = data.pbs + .map((e: PBScoreDocument) => e.calculatedData[alg]) + .sort(NumericSOV((x: number) => x ?? -Infinity, true)); + + for (const pb of data.pbs.slice(0, count)) { + const song = songMap.get(pb.songID); + const chart = chartMap.get(pb.chartID); + + if (song === undefined || chart === undefined) { + continue; + } + + flatDataset.push({ + ...pb, + __related: { + chart, + song, + index: sortedRatings.indexOf(pb.calculatedData[alg]), + }, + }); + } + + return flatDataset; +} + +function ColumnMerge(flatDataset: PBDataset, columns: number) { + const compoundDataset: PBDataset[] = [[]]; + for (const d of flatDataset) { + const back = compoundDataset[compoundDataset.length - 1]; + back.push(d); + if (back.length === columns) { + compoundDataset.push([]); + } + } + return compoundDataset.filter((p) => p.length > 0); +} + +export const ONGEKIClassicBreakdownInsight: GPTUtility = { name: "O.N.G.E.K.I. Classic Rating Breakdown", - urlPath: "rating", - description: `See what PBs are going into your classic profile rating!`, - component: Component, + urlPath: "classic-rating", + description: `See what PBs are going into your NaiveRatingClassic!`, + component: ComponentClassic, + personalUseOnly: false, +}; + +export const ONGEKIRefreshBreakdownInsight: GPTUtility = { + name: "O.N.G.E.K.I. Refresh Rating Breakdown", + urlPath: "refresh-rating", + description: `See what PBs are going into your NaiveRatingRefresh!`, + component: ComponentRefresh, personalUseOnly: false, }; diff --git a/client/src/components/tables/cells/OngekiDamageCell.tsx b/client/src/components/tables/cells/OngekiDamageCell.tsx new file mode 100644 index 000000000..24368ef7f --- /dev/null +++ b/client/src/components/tables/cells/OngekiDamageCell.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { COLOUR_SET } from "tachi-common"; + +export default function OngekiDamageCell({ damage }: { damage: number | null | undefined }) { + return ( + + {damage ?? "?"} + + ); +} diff --git a/client/src/components/tables/cells/OngekiJudgementCell.tsx b/client/src/components/tables/cells/OngekiJudgementCell.tsx index b8193ba26..07df9603b 100644 --- a/client/src/components/tables/cells/OngekiJudgementCell.tsx +++ b/client/src/components/tables/cells/OngekiJudgementCell.tsx @@ -19,7 +19,7 @@ export default function OngekiJudgementCell({ } return ( - +
{judgements.cbreak}- @@ -27,14 +27,9 @@ export default function OngekiJudgementCell({ {judgements.hit}- {judgements.miss}
-
- - {score.scoreData.optional.bellCount ?? "?"}/ - {score.scoreData.optional.totalBellCount ?? "?"} - - - {score.scoreData.optional.damage ?? "?"} - +
+ {score.scoreData.optional.bellCount ?? "?"}/ + {score.scoreData.optional.totalBellCount ?? "?"}
diff --git a/client/src/components/tables/cells/OngekiLampCell.tsx b/client/src/components/tables/cells/OngekiLampCell.tsx index 45725a39f..2efdc7abc 100644 --- a/client/src/components/tables/cells/OngekiLampCell.tsx +++ b/client/src/components/tables/cells/OngekiLampCell.tsx @@ -7,7 +7,7 @@ export default function OngekiLampCell({ bellLamp, colour, }: { - noteLamp: "ALL BREAK" | "FULL COMBO" | "CLEAR" | "LOSS"; + noteLamp: "ALL BREAK+" | "ALL BREAK" | "FULL COMBO" | "CLEAR" | "LOSS"; bellLamp: "FULL BELL" | "NONE"; colour: string; }) { @@ -32,6 +32,7 @@ export default function OngekiLampCell({ return ( {compact ? FmtStarsCompact(stars) : FmtStars(stars)}; + } + return ( + + ★★★★★ + + ); +} export default function OngekiPlatinumCell({ - platScore: platScore, + platinumScore: platinumScore, maxPlatScore: maxPlatScore, - difficulty, + stars: stars, }: { - platScore: integer | null | undefined; + platinumScore: integer; maxPlatScore: integer; - difficulty: Difficulties["ongeki:Single"]; + stars: number; }) { - if (difficulty !== "MASTER" && difficulty !== "LUNATIC") { - return N/A; - } - - if (platScore === null || platScore === undefined) { - return Unknown; - } + const percentage = Math.round((platinumScore * 10000.0) / maxPlatScore) / 100.0; return ( - MAX-{maxPlatScore - platScore} - {platScore !== undefined && ( - <> -
- - [{platScore}/{maxPlatScore}] - - - )} +
+ {percentage.toFixed(2)}% + + + [{platinumScore}/{maxPlatScore}] + +
); } diff --git a/client/src/components/tables/cells/OngekiScoreRatingCell.tsx b/client/src/components/tables/cells/OngekiScoreRatingCell.tsx new file mode 100644 index 000000000..b35dc4af4 --- /dev/null +++ b/client/src/components/tables/cells/OngekiScoreRatingCell.tsx @@ -0,0 +1,96 @@ +import { FormatMillions, FormatScoreRating } from "util/misc"; +import { ChangeOpacity } from "util/color-opacity"; +import React from "react"; +import { PBScoreDocument, ScoreDocument } from "tachi-common"; +import QuickTooltip from "components/layout/misc/QuickTooltip"; +import { GPT_CLIENT_IMPLEMENTATIONS } from "lib/game-implementations"; +import MiniTable from "../components/MiniTable"; +import LampCell from "./LampCell"; + +export default function OngekiScoreRatingCell({ + score, +}: { + score: ScoreDocument<"ongeki:Single"> | PBScoreDocument<"ongeki:Single">; +}) { + const ratingValue = score.calculatedData.scoreRating ?? 0; + const noteLamp = score.scoreData.noteLamp; + const bellLamp = score.scoreData.bellLamp; + const grade = score.scoreData.grade; + + const gradeColor = ChangeOpacity( + GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].enumColours.grade[score.scoreData.grade], + 0.2 + ); + + const gradeRating = grade === "SSS+" ? 0.3 : grade === "SSS" ? 0.2 : grade === "SS" ? 0.1 : 0; + const noteRating = + noteLamp === "ALL BREAK+" + ? 0.35 + : noteLamp === "ALL BREAK" + ? 0.3 + : noteLamp === "FULL COMBO" + ? 0.1 + : 0; + const bellRating = bellLamp === "FULL BELL" ? 0.05 : 0; + const techRating = ratingValue - gradeRating - noteRating - bellRating; + + return ( + <> + + + + + {FormatMillions(score.scoreData.score)} + + {techRating.toFixed(3)} + + + + {noteRating.toFixed(3)} + + + + {bellRating.toFixed(3)} + + + + {score.scoreData.grade} + + {gradeRating.toFixed(3)} + + + + } + > + +
+ {FormatScoreRating(score.game, score.playtype, "scoreRating", ratingValue)} +
+ +
+ + ); +} diff --git a/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx b/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx index 24ba02a2e..2c51deddf 100644 --- a/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx +++ b/client/src/components/tables/dropdowns/components/OngekiScoreDropdownParts.tsx @@ -3,7 +3,6 @@ import React, { useState } from "react"; import { Nav } from "react-bootstrap"; import { ChartDocument, - Difficulties, PBScoreDocument, ScoreData, ScoreDocument, @@ -12,7 +11,7 @@ import { import GekichumaiScoreChart from "components/charts/GekichumaiScoreChart"; import useApiQuery from "components/util/query/useApiQuery"; -type ChartType = "Score" | "Bells" | "Life"; +type ChartType = "Score" | "Platinum" | "Bells" | "Life"; export function OngekiGraphsComponent({ score, @@ -29,6 +28,9 @@ export function OngekiGraphsComponent({ score.scoreData.optional.totalBellCount !== null && score.scoreData.optional.totalBellCount !== undefined; + // Platinum graphs were added later so they need a separate check + const availablePlat = score.scoreData.optional.platinumGraph; + if (!available) { return ; } @@ -48,6 +50,14 @@ export function OngekiGraphsComponent({ Score + + P-Score + Bells @@ -61,7 +71,7 @@ export function OngekiGraphsComponent({ type={graph} scoreData={score.scoreData} song={song} - difficulty={chart.difficulty} + chart={chart} />
@@ -72,26 +82,30 @@ function GraphComponent({ type, scoreData, song, - difficulty, + chart, }: { type: ChartType; scoreData: ScoreData<"ongeki:Single">; song: SongDocument<"ongeki">; - difficulty: Difficulties["ongeki:Single"]; + chart: ChartDocument<"ongeki:Single">; }) { const values = type === "Score" ? scoreData.optional.scoreGraph! : type === "Bells" ? scoreData.optional.bellGraph! - : scoreData.optional.lifeGraph!; + : type === "Life" + ? scoreData.optional.lifeGraph! + : scoreData.optional.platinumGraph!; return ( x.scoreData.score * 10000 + (x.scoreData.optional.platScore ?? 0) - ), + NumericSOV((x) => x.scoreData.score * 10000 + x.scoreData.platinumScore), + ], + [ + "Platinum Score", + "P-Score", + NumericSOV((x) => x.scoreData.platinumStars * 100000 + x.scoreData.platinumScore), + ], + ["Judgements", "Notes", NumericSOV((x) => x.scoreData.judgements.cbreak ?? 0)], + [ + "Hits", + "Hits", + NumericSOV((x) => x.scoreData.optional.damage ?? Number.MAX_SAFE_INTEGER, true), ], - // TODO: this should be sorted by % - ["Platinum Score", "P-Score", NumericSOV((x) => x.scoreData.optional.platScore ?? 0)], - ["Judgements", "Hits", NumericSOV((x) => x.scoreData.score)], [ "Lamp", "Lamp", @@ -852,11 +859,12 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = { colour={GetEnumColour(sc, "grade")} /> + ), - ratingCell: ({ sc, rating }) => , + ratingCell: ({ sc, rating }) => ( + <> + {rating === "scoreRating" ? ( + + ) : ( + + )} + + ), }, "arcaea:Touch": ARCAEA_TOUCH_IMPL, "gitadora:Dora": GITADORA_DORA_IMPL, diff --git a/client/src/lib/games/_util.ts b/client/src/lib/games/_util.ts index 706fe8d2d..ecd0746a6 100644 --- a/client/src/lib/games/_util.ts +++ b/client/src/lib/games/_util.ts @@ -36,6 +36,12 @@ export const RAINBOW_GRADIENT = { color: "var(--bs-dark)", } as const; +export const RAINBOW_SHINY_GRADIENT = { + background: + "linear-gradient(-45deg, #ff8697, #ff9dbf, #9e81d0, #87caff, #7dae80, #ffffa7, #ffd88a, #ffab8d, #ff8697)", + color: "var(--bs-dark)", +} as const; + export const RAINBOW_EX_GRADIENT = { background: "linear-gradient(-45deg, #0fa091, #0f98d5, #67087f, #d9007e, #f56e06)", color: "var(--bs-light)", diff --git a/common/src/config/game-support/ongeki.ts b/common/src/config/game-support/ongeki.ts index 8218fb356..1119c49c7 100644 --- a/common/src/config/game-support/ongeki.ts +++ b/common/src/config/game-support/ongeki.ts @@ -1,5 +1,5 @@ import { FAST_SLOW_MAXCOMBO } from "./_common"; -import { FmtNum } from "../../utils/util"; +import { FmtNum, FmtStars } from "../../utils/util"; import { ClassValue, ToDecimalPlaces } from "../config-utils"; import { p } from "prudence"; import { z } from "zod"; @@ -24,16 +24,18 @@ export const ONGEKI_CONF = { } as const satisfies INTERNAL_GAME_CONFIG; export const OngekiColours = [ - ClassValue("BLUE", "青", "Blue: 0 - 1.99 Rating"), - ClassValue("GREEN", "緑", "Green: 2 - 3.99 Rating"), - ClassValue("ORANGE", "橙", "Orange: 4 - 6.99 Rating"), - ClassValue("RED", "赤", "Red: 7 - 9.99 Rating"), - ClassValue("PURPLE", "紫", "Purple: 10 - 11.99 Rating"), - ClassValue("COPPER", "銅", "Copper: 12 - 12.99 Rating"), - ClassValue("SILVER", "銀", "Silver: 13 - 13.99 Rating"), - ClassValue("GOLD", "金", "Gold: 14.00 - 14.49 Rating"), - ClassValue("PLATINUM", "鉑", "Platinum: 14.50 - 14.99 Rating"), - ClassValue("RAINBOW", "虹", "Rainbow: >=15 Rating"), + ClassValue("BLUE", "水", "Blue: 0.000~3.999 Rating"), + ClassValue("GREEN", "緑", "Green: 4.000~6.999 Rating"), + ClassValue("ORANGE", "橙", "Orange: 7.000~8.999 Rating"), + ClassValue("RED", "赤", "Red: 9.000~10.999 Rating"), + ClassValue("PURPLE", "紫", "Purple: 11.000~12.999 Rating"), + ClassValue("COPPER", "銅", "Copper: 13.000~14.999 Rating"), + ClassValue("SILVER", "銀", "Silver: 15.000~16.999 Rating"), + ClassValue("GOLD", "金", "Gold: 17.000~17.999 Rating"), + ClassValue("PLATINUM", "鉑", "Platinum: 18.000~18.999 Rating"), + ClassValue("RAINBOW", "虹", "Rainbow: 19.000~19.999 Rating"), + ClassValue("RAINBOW_SHINY", "虹(光)", "Rainbow Shiny: 20.000~20.999 Rating"), + ClassValue("RAINBOW_EX", "虹(極)", "Rainbow Extreme: 21.000~ Rating"), ]; export const ONGEKI_SINGLE_CONF = { @@ -47,7 +49,7 @@ export const ONGEKI_SINGLE_CONF = { }, noteLamp: { type: "ENUM", - values: ["LOSS", "CLEAR", "FULL COMBO", "ALL BREAK"], + values: ["LOSS", "CLEAR", "FULL COMBO", "ALL BREAK", "ALL BREAK+"], minimumRelevantValue: "CLEAR", description: "The primary lamp. A clear is either a draw or a win.", }, @@ -57,6 +59,13 @@ export const ONGEKI_SINGLE_CONF = { minimumRelevantValue: "FULL BELL", description: "Tracks whether all bells in the chart have been collected.", }, + platinumScore: { + type: "INTEGER", + chartDependentMax: true, + formatter: FmtNum, + description: + "The Platinum Score value, similar to the scoring system used in beatmania IIDX.", + }, }, derivedMetrics: { @@ -66,6 +75,12 @@ export const ONGEKI_SINGLE_CONF = { minimumRelevantValue: "A", description: "The grade this score was.", }, + platinumStars: { + type: "INTEGER", + validate: p.isBetween(0, 6), + formatter: FmtStars, + description: "The number of platinum stars of this score", + }, }, defaultMetric: "score", @@ -93,18 +108,16 @@ export const ONGEKI_SINGLE_CONF = { formatter: FmtNum, description: "The total number of bells.", }, - platScore: { - type: "INTEGER", - chartDependentMax: true, - formatter: FmtNum, - description: "The Platinum Score value. Only exists in MASTER and LUNATIC charts.", - partOfScoreID: true, - }, scoreGraph: { type: "NULLABLE_GRAPH", validate: p.isBetween(0, 1010000), description: "The history of the projected score, queried in one-second intervals.", }, + platinumGraph: { + type: "NULLABLE_GRAPH", + validate: p.isBetween(-100000, 0), + description: "The Platinum Score history, queried in one-second intervals.", + }, bellGraph: { type: "NULLABLE_GRAPH", validate: p.isBetween(-10000, 0), @@ -124,12 +137,30 @@ export const ONGEKI_SINGLE_CONF = { "A rating value of this score, capping at +2.0 at SSS+. This is identical to the system used in bright MEMORY and earlier versions.", formatter: ToDecimalPlaces(2), }, + scoreRating: { + description: + "A rating value of this score, capping at +2.7 at 1,010,000. This is identical to the system used in Re:Fresh.", + formatter: ToDecimalPlaces(3), + }, + starRating: { + description: + "A rating value of this score, based on stars derived from Platinum Score. This is identical to the system used in Re:Fresh.", + formatter: ToDecimalPlaces(3), + }, }, sessionRatingAlgs: { naiveRating: { description: "The average of your best 10 classic ratings this session.", formatter: ToDecimalPlaces(2), }, + naiveScoreRating: { + description: "The average of your best 10 score ratings this session.", + formatter: ToDecimalPlaces(3), + }, + starRating: { + description: "The average of your best 10 star ratings this session.", + formatter: ToDecimalPlaces(3), + }, }, profileRatingAlgs: { naiveRating: { @@ -137,6 +168,11 @@ export const ONGEKI_SINGLE_CONF = { "The average of your best 45 classic ratings. This is a simpler variant of the rating algorithm used in bright MEMORY and earlier versions, without distinguishing between new and old charts, and without taking recent scores into account.", formatter: ToDecimalPlaces(2), }, + naiveRatingRefresh: { + description: + "A weighted sum of the average of your best 60 score ratings, and your best 50 star ratings. This is a simpler variant of the rating algorithm used in Re:Fresh, without distinguishing between new and old charts.", + formatter: ToDecimalPlaces(3), + }, }, defaultScoreRatingAlg: "rating", diff --git a/common/src/constants/game.ts b/common/src/constants/game.ts index c65f958a6..dde24ae54 100644 --- a/common/src/constants/game.ts +++ b/common/src/constants/game.ts @@ -579,6 +579,7 @@ export enum ONGEKI_NOTE_LAMPS { CLEAR = 1, FULL_COMBO = 2, ALL_BREAK = 3, + ALL_BREAK_PLUS = 4, } export enum ONGEKI_BELL_LAMPS { @@ -612,4 +613,6 @@ export enum ONGEKI_COLOURS { GOLD = 7, PLATINUM = 8, RAINBOW = 9, + RAINBOW_SHINY = 10, + RAINBOW_EX = 11, } diff --git a/common/src/utils/util.ts b/common/src/utils/util.ts index 95c5685aa..7a2d54bd1 100644 --- a/common/src/utils/util.ts +++ b/common/src/utils/util.ts @@ -287,6 +287,28 @@ export function FmtPercent(v: number, dp = 2) { return `${v.toFixed(dp)}%`; } +/** + * Formats a number into "★★★☆☆" + */ +export function FmtStars(num: number) { + if (num > 5) { + return "★★★★★(虹)"; + } + + return "★".repeat(num) + "☆".repeat(5 - num); +} + +/** + * Formats a number into "★★★" + */ +export function FmtStarsCompact(num: number) { + if (num > 5) { + return "★★★★★(虹)"; + } + + return "★".repeat(num); +} + /** * Turns a number of 12834 into "12834" instead of "12,834". */ diff --git a/docs/docs/game-support/games/ongeki-Single.md b/docs/docs/game-support/games/ongeki-Single.md index 45e6f4787..95fc460f7 100644 --- a/docs/docs/game-support/games/ongeki-Single.md +++ b/docs/docs/game-support/games/ongeki-Single.md @@ -14,8 +14,9 @@ For more information on what metrics are and how they work, see [TODO]! | Metric Name | Type | Description | | :: | :: | :: | | `score` | Integer | Known in-game as 'Technical Score'. It ranges between 0 and 1,010,000, where notes are worth 950,000, and bells 60,000. | -| `noteLamp` | "LOSS", "CLEAR", "FULL COMBO", "ALL BREAK" | The primary lamp. A clear is a draw or a win in-game. | +| `noteLamp` | "LOSS", "CLEAR", "FULL COMBO", "ALL BREAK", "ALL BREAK+" | The primary lamp. A clear is a draw or a win in-game. | | `bellLamp` | "NONE", "FULL BELL" | Tracks whether all bells in the chart have been collected. | +| `platinumScore` | Integer | The Platinum Score value. | ### Derived Metrics @@ -33,14 +34,13 @@ For more information on what metrics are and how they work, see [TODO]! | `damage` | Integer | The number of damage ticks received. | | `bellCount` | Integer | The number of bells collected. | | `totalBellCount` | Integer | The maximum number of bells that could have been obtained at the time of the play's end. | -| `platScore` | Integer | The Platinum Score value. Only exists in MASTER and LUNATIC charts. | ## Judgements The following judgements are defined: -- `cbreak` -- `break` +- `cbreak` (critical break) +- `break` (red/regular break) - `hit` - `miss` @@ -51,18 +51,23 @@ The following judgements are defined: | Name | Description | | :: | :: | | `rating` | A rating value of this score, capping at +2.0 at SSS+. This is identical to the system used in bright MEMORY and earlier versions. | +| `scoreRating` | A rating value of this score, capping at +2.7 at 1,010,000. This is identical to the system used in Re:Fresh. | +| `starRating` | A rating value of this score, based on stars derived from Platinum Score. This is identical to the system used in Re:Fresh. | ### Session Rating Algorithms | Name | Description | | :: | :: | | `naiveRating` | The average of your best 10 classic ratings this session. | +| `naiveScoreRating` | The average of your best 10 score ratings this session. | +| `starRating` | The average of your best 10 star ratings this session. | ### Profile Rating Algorithms | Name | Description | | :: | :: | | `naiveRating` | The average of your best 45 classic ratings. This is a simpler variant of the rating algorithm used in bright MEMORY and earlier versions, without distinguishing between new and old charts, and without taking recent scores into account. | +| `naiveRatingRefresh` | A weighted sum of the average of your best 60 score ratings, and your best 50 star ratings. This is a simpler variant of the rating algorithm used in Re:Fresh, without distinguishing between new and old charts. | ## Difficulties @@ -82,8 +87,11 @@ The following judgements are defined: | ID | Pretty Name | | :: | :: | +| `brightMemory2Omni` | bright MEMORY Act.2 Omnimix | | `brightMemory3` | bright MEMORY Act.3 | | `brightMemory3Omni` | bright MEMORY Act.3 Omnimix | +| `refresh` | Re:Fresh | +| `refreshOmni` | Re:Fresh Omnimix | ## Supported Match Types diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e9fa73a6..6aae7504b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10238,6 +10238,10 @@ packages: resolution: {integrity: sha512-KQxSjnHXvb2B9Z+y/OwtUzStIjyXsn78c1XSF0t5L9G5UsVjyyO250eq4ih00X1WjvPRE9AtC4h38gBZEWClkw==} dev: false + /rg-stats@1.0.0: + resolution: {integrity: sha512-Itt3OJden5riCkSY7o5whJTqK/DQNteJdQ+AXZ2upNNESOO/7JnWx+n/jJiSimR+XOE/rSggTxXLujHcE4Z0nA==} + dev: false + /rimraf@2.4.5: resolution: {integrity: sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==} deprecated: Rimraf versions prior to v4 are no longer supported diff --git a/server/package.json b/server/package.json index 2e87a52c0..56fbbad79 100644 --- a/server/package.json +++ b/server/package.json @@ -126,4 +126,4 @@ "node": "20", "pnpm": "8" } -} \ No newline at end of file +} diff --git a/server/src/game-implementations/games/ongeki.test.ts b/server/src/game-implementations/games/ongeki.test.ts index 72fe2a0d6..039cc260f 100644 --- a/server/src/game-implementations/games/ongeki.test.ts +++ b/server/src/game-implementations/games/ongeki.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import { ONGEKI_IMPL } from "./ongeki"; import db from "external/mongo/db"; import CreateLogCtx from "lib/logger/logger"; @@ -13,15 +15,18 @@ const baseMetrics: ProvidedMetrics["ongeki:Single"] = { noteLamp: "CLEAR", bellLamp: "FULL BELL", score: 1_001_500, + platinumScore: 970, }; const scoreData: ScoreData<"ongeki:Single"> = { noteLamp: "CLEAR", bellLamp: "FULL BELL", score: 1_001_500, + platinumScore: 970, + platinumStars: 4, grade: "SSS", judgements: {}, - optional: { enumIndexes: {}, bellCount: 100, platScore: 1000 }, + optional: { enumIndexes: {}, bellCount: 100 }, enumIndexes: { grade: ONGEKI_GRADES.SSS, noteLamp: ONGEKI_NOTE_LAMPS.CLEAR, @@ -34,9 +39,9 @@ const mockPB = mkMockPB("ongeki", "Single", TestingOngekiChart, scoreData); const logger = CreateLogCtx(__filename); -t.test("ONGEKI Implementation", (t) => { - t.test("Grade Deriver", (t) => { - const f = (score: number, expected: any) => +t.test("ONGEKI Implementation", (t: any) => { + t.test("Grade Deriver", (t: any) => { + const f = (score: number, expected: string) => t.equal( ONGEKI_IMPL.derivers.grade(dmf(baseMetrics, { score }), TestingOngekiChart), expected, @@ -60,11 +65,47 @@ t.test("ONGEKI Implementation", (t) => { t.end(); }); - t.test("Rating Calc", (t) => { + t.test("Star Deriver", (t: any) => { + const f = (platinumScore: number, expected: number) => + t.equal( + ONGEKI_IMPL.derivers.platinumStars( + dmf(baseMetrics, { platinumScore }), + TestingOngekiChart + ), + expected, + `A score of ${platinumScore.toLocaleString()} should result in stars=${expected}.` + ); + + f(939, 0); + f(940, 1); + f(950, 2); + f(960, 3); + f(970, 4); + f(980, 5); + f(990, 6); + f(1000, 6); + + t.end(); + }); + + t.test("Rating Calc", (t: any) => { t.equal( ONGEKI_IMPL.scoreCalcs.rating(scoreData, TestingOngekiChart), 12.1, - "Basic rating check" + "Basic classic rating check" + ); + + t.equal( + ONGEKI_IMPL.scoreCalcs.scoreRating(scoreData, TestingOngekiChart), + 12.1, + "Basic score rating check" + ); + + t.equal( + ONGEKI_IMPL.scoreCalcs.starRating(scoreData, TestingOngekiChart), + Math.floor(scoreData.platinumStars * (TestingOngekiChart.levelNum * 10) ** 2) / + 100000.0, + "Basic star rating check" ); t.end(); @@ -72,36 +113,80 @@ t.test("ONGEKI Implementation", (t) => { t.todo("Session Calcs"); - t.test("Profile Calcs", (t) => { + t.test("Profile Calcs", (t: any) => { t.beforeEach(ResetDBState); - const mockPBs = async (ratings: Array) => { + const mockPBs = async ( + ratings: Array, + field: "rating" | "scoreRating" | "starRating" + ) => { await Promise.all( - ratings.map((rating, idx) => - db["personal-bests"].insert({ + ratings.map((rating, idx) => { + const ratings = + // Leave me and my ternaries alone + // eslint-disable-next-line no-nested-ternary + field === "rating" + ? { rating, scoreRating: 0, starRating: 0 } + : field === "scoreRating" + ? { rating: 0, scoreRating: rating, starRating: 0 } + : { rating: 0, scoreRating: 0, starRating: rating }; + + return db["personal-bests"].insert({ ...TestingOngekiScorePB, - chartID: `TEST${idx}`, + chartID: `TEST${field}${idx}`, calculatedData: { ...TestingOngekiScorePB.calculatedData, - rating, + ...ratings, }, - }) - ) + }); + }) ); }; - t.test("Floating-point edge case", async (t) => { - await mockPBs(Array(45).fill(16.27)); + t.test("Floating-point edge case", async (t: any) => { + await mockPBs(Array(45).fill(16.27), "rating"); + await mockPBs(Array(60).fill(16.271), "scoreRating"); t.equal(await ONGEKI_IMPL.profileCalcs.naiveRating("ongeki", "Single", 1), 16.27); + t.equal( + await ONGEKI_IMPL.profileCalcs.naiveRatingRefresh("ongeki", "Single", 1), + 19.525 + ); + + await mockPBs([1, 1, 0, 0], "starRating"); + + t.equal( + await ONGEKI_IMPL.profileCalcs.naiveRatingRefresh("ongeki", "Single", 1), + 19.565 + ); t.end(); }); - t.test("Profile with fewer than 45 scores", async (t) => { - await mockPBs([16, 16, 16, 16]); + t.test("Profile with few scores #1", async (t: any) => { + await mockPBs([16, 16, 16, 16], "rating"); + await mockPBs([16, 16, 16, 16], "scoreRating"); t.equal(await ONGEKI_IMPL.profileCalcs.naiveRating("ongeki", "Single", 1), 1.42); + t.equal( + await ONGEKI_IMPL.profileCalcs.naiveRatingRefresh("ongeki", "Single", 1), + 1.279 + ); + + await mockPBs([1, 1, 0, 0], "starRating"); + + t.equal( + await ONGEKI_IMPL.profileCalcs.naiveRatingRefresh("ongeki", "Single", 1), + 1.319 + ); + + t.end(); + }); + + t.test("Profile with few scores #2", async (t: any) => { + await mockPBs([1, 1, 0, 0], "starRating"); + + t.equal(await ONGEKI_IMPL.profileCalcs.naiveRatingRefresh("ongeki", "Single", 1), 0.04); t.end(); }); @@ -109,40 +194,49 @@ t.test("ONGEKI Implementation", (t) => { t.end(); }); - t.test("Colour Deriver", (t) => { - const f = (v: number | null, expected: any) => + t.test("Colour Deriver", (t: any) => { + const f = (v: number | null, expected: string | null) => t.equal( - ONGEKI_IMPL.classDerivers.colour({ naiveRating: v }), + ONGEKI_IMPL.classDerivers.colour({ naiveRatingRefresh: v }), expected, - `A naiveRating of ${v} should result in ${expected}.` + `A naiveRatingRefresh of ${v} should result in ${expected}.` ); f(null, null); f(0, "BLUE"); - f(2, "GREEN"); - f(4, "ORANGE"); - f(7, "RED"); - f(10, "PURPLE"); - f(12, "COPPER"); - f(13, "SILVER"); - f(14, "GOLD"); - f(14.5, "PLATINUM"); - f(15, "RAINBOW"); + f(4, "GREEN"); + f(7, "ORANGE"); + f(9, "RED"); + f(11, "PURPLE"); + f(13, "COPPER"); + f(15, "SILVER"); + f(17, "GOLD"); + f(18, "PLATINUM"); + f(19, "RAINBOW"); + f(20, "RAINBOW_SHINY"); + f(21, "RAINBOW_EX"); t.end(); }); - t.test("Goal Formatters", (t) => { - t.test("Criteria", (t) => { + t.test("Goal Formatters", (t: any) => { + t.test("Criteria", (t: any) => { t.equal( ONGEKI_IMPL.goalCriteriaFormatters.score(1_008_182), "Get a score of 1,008,182 on" ); + t.equal( + ONGEKI_IMPL.goalCriteriaFormatters.platinumScore(1500), + "Get 1,500 Platinum Score on" + ); + + t.equal(ONGEKI_IMPL.goalCriteriaFormatters.platinumStars(3), "Get ★★★☆☆ on"); + t.end(); }); - t.test("Progress", (t) => { + t.test("Progress", (t: any) => { const f = ( k: keyof typeof ONGEKI_IMPL.goalProgressFormatters, modifant: Partial>, @@ -160,15 +254,18 @@ t.test("ONGEKI Implementation", (t) => { ); f("grade", { grade: "S", score: 987_342 }, ONGEKI_GRADES.S, "SS-2.7K"); - f("score", { score: 982_123 }, 1_000_000, "982,123"); f("noteLamp", { noteLamp: "CLEAR" }, ONGEKI_NOTE_LAMPS.CLEAR, "CLEAR"); + f("bellLamp", { bellLamp: "FULL BELL" }, ONGEKI_BELL_LAMPS.FULL_BELL, "FULL BELL"); + f("score", { score: 982_123 }, 1_000_000, "982,123"); + f("platinumScore", { platinumScore: 1234 }, 2345, "1,234"); t.end(); }); - t.test("Out Of", (t) => { + t.test("Out Of", (t: any) => { t.equal(ONGEKI_IMPL.goalOutOfFormatters.score(1_001_003), "1,001,003"); t.equal(ONGEKI_IMPL.goalOutOfFormatters.score(983_132), "983,132"); + t.equal(ONGEKI_IMPL.goalOutOfFormatters.platinumScore(1234), "1,234"); t.end(); }); @@ -176,10 +273,10 @@ t.test("ONGEKI Implementation", (t) => { t.end(); }); - t.test("PB Merging", (t) => { + t.test("PB Merging", (t: any) => { t.beforeEach(ResetDBState); - t.test("Should join best lamp", async (t) => { + t.test("Should join best lamp", async (t: any) => { await db.scores.insert(mockScore); await db.scores.insert( dmf(mockScore, { @@ -211,21 +308,46 @@ t.test("ONGEKI Implementation", (t) => { t.end(); }); + t.test("Should join platinum score", async (t: any) => { + await db.scores.insert(mockScore); + await db.scores.insert( + dmf(mockScore, { + scoreID: "bestPlatinum", + scoreData: { + score: 0, + platinumScore: 990, + platinumStars: 6, + }, + }) + ); + + t.hasStrict(await CreatePBDoc("ongeki:Single", 1, TestingOngekiChart, logger), { + composedFrom: [ + { name: "Best Score" }, + { name: "Best Platinum Score", scoreID: "bestPlatinum" }, + ], + scoreData: { + score: mockScore.scoreData.score, + platinumScore: 990, + platinumStars: 6, + }, + }); + + t.end(); + }); + t.end(); }); - t.test("Ranking", (t) => { + t.test("Ranking", (t: any) => { t.beforeEach(ResetDBState); - t.test("Should tiebreak according to platinum score", async (t) => { + t.test("Should tiebreak according to platinum score", async (t: any) => { await db["personal-bests"].insert({ ...TestingOngekiScorePB, scoreData: { ...TestingOngekiScorePB.scoreData, - optional: { - ...TestingOngekiScorePB.scoreData.optional, - platScore: 1000, - }, + platinumScore: 1000, }, timeAchieved: 3, userID: 1, @@ -235,10 +357,7 @@ t.test("ONGEKI Implementation", (t) => { ...TestingOngekiScorePB, scoreData: { ...TestingOngekiScorePB.scoreData, - optional: { - ...TestingOngekiScorePB.scoreData.optional, - platScore: 1001, - }, + platinumScore: 1001, }, timeAchieved: 2, userID: 2, @@ -248,10 +367,7 @@ t.test("ONGEKI Implementation", (t) => { ...TestingOngekiScorePB, scoreData: { ...TestingOngekiScorePB.scoreData, - optional: { - ...TestingOngekiScorePB.scoreData.optional, - platScore: 999, - }, + platinumScore: 999, }, timeAchieved: 1, userID: 3, @@ -267,9 +383,9 @@ t.test("ONGEKI Implementation", (t) => { for (const pb of pbs) { t.strictSame(pb.rankingData.outOf, 3); - if (pb.scoreData.optional.platScore === 999) { + if (pb.scoreData.platinumScore === 999) { t.strictSame(pb.rankingData.rank, 3); - } else if (pb.scoreData.optional.platScore === 1000) { + } else if (pb.scoreData.platinumScore === 1000) { t.strictSame(pb.rankingData.rank, 2); } else { t.strictSame(pb.rankingData.rank, 1); @@ -279,16 +395,13 @@ t.test("ONGEKI Implementation", (t) => { t.end(); }); - t.test("Should not tiebreak if tech scores differ", async (t) => { + t.test("Should not tiebreak if tech scores differ", async (t: any) => { await db["personal-bests"].insert({ ...TestingOngekiScorePB, scoreData: { ...TestingOngekiScorePB.scoreData, score: 1010000, - optional: { - ...TestingOngekiScorePB.scoreData.optional, - platScore: 0, - }, + platinumScore: 0, }, userID: 1, }); @@ -298,10 +411,7 @@ t.test("ONGEKI Implementation", (t) => { scoreData: { ...TestingOngekiScorePB.scoreData, score: 1009999, - optional: { - ...TestingOngekiScorePB.scoreData.optional, - platScore: 1001, - }, + platinumScore: 1001, }, userID: 2, }); @@ -322,8 +432,8 @@ t.test("ONGEKI Implementation", (t) => { t.strictSame(pb1?.rankingData.outOf, 2); t.strictSame(pb1?.rankingData.rank, 1); t.strictSame(pb2?.rankingData.rank, 2); - t.strictSame(pb1?.scoreData.optional.platScore, 0); - t.strictSame(pb2?.scoreData.optional.platScore, 1001); + t.strictSame(pb1?.scoreData.platinumScore, 0); + t.strictSame(pb2?.scoreData.platinumScore, 1001); t.end(); }); diff --git a/server/src/game-implementations/games/ongeki.ts b/server/src/game-implementations/games/ongeki.ts index e724bd53b..6348967c2 100644 --- a/server/src/game-implementations/games/ongeki.ts +++ b/server/src/game-implementations/games/ongeki.ts @@ -4,25 +4,22 @@ import { CreatePBMergeFor } from "game-implementations/utils/pb-merge"; import { ProfileAvgBestN } from "game-implementations/utils/profile-calc"; import { SessionAvgBest10For } from "game-implementations/utils/session-calc"; import { ONGEKIRating } from "rg-stats"; -import { ONGEKI_GBOUNDARIES, FmtNum, GetGrade } from "tachi-common"; +import { ONGEKI_GBOUNDARIES, FmtNum, GetGrade, FmtStars, FmtStarsCompact } from "tachi-common"; import { IsNullish } from "utils/misc"; import type { GPTServerImplementation } from "game-implementations/types"; +import type { ChartDocument, Game, integer, Playtype } from "tachi-common"; -const isBonusTrack = (inGameID: number) => inGameID >= 7000 && inGameID < 8000; +const isUnranked = (chart: ChartDocument<"ongeki:Single">) => + (chart.data.inGameID >= 7000 && chart.data.inGameID < 8000) || chart.levelNum === 0.0; + +const starCount = (platinumScore: number, maxPlatinumScore: number) => { + const pct = Math.floor((platinumScore / maxPlatinumScore) * 100); + + return Math.max(0, Math.min(pct, 99) - 93); +}; export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { chartSpecificValidators: { - platScore: (platScore, chart) => { - if (platScore < 0) { - return `Platinum Score must be non-negative. Got ${platScore}`; - } - - if (platScore > chart.data.maxPlatScore) { - return `Platinum Score is too large: ${platScore}>${chart.data.maxPlatScore}`; - } - - return true; - }, bellCount: (bellCount) => { if (bellCount < 0) { return `Bell Count must be non-negative. Got ${bellCount}`; @@ -42,47 +39,93 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { return `Damage must be non-negative. Got ${damage}`; } + return true; + }, + platinumScore: (platinumScore, chart) => { + if (platinumScore < 0) { + return `Platinum Score must be non-negative. Got ${platinumScore}`; + } + + if (platinumScore > chart.data.maxPlatScore) { + return `Platinum Score must not exceed the chart's maximum Platinum Score. Got ${platinumScore}/${chart.data.maxPlatScore}`; + } + return true; }, }, derivers: { grade: ({ score }) => GetGrade(ONGEKI_GBOUNDARIES, score), + platinumStars: ({ platinumScore }, chart) => + starCount(platinumScore, chart.data.maxPlatScore), }, scoreCalcs: { rating: (scoreData, chart) => - isBonusTrack(chart.data.inGameID) || chart.levelNum === 0.0 + isUnranked(chart) ? 0 : ONGEKIRating.calculate(scoreData.score, chart.levelNum), + scoreRating: (scoreData, chart) => + isUnranked(chart) ? 0 - : ONGEKIRating.calculate(scoreData.score, chart.levelNum), + : ONGEKIRating.calculateRefresh( + chart.levelNum, + scoreData.score, + scoreData.score === 1010000 ? "ALL BREAK+" : scoreData.noteLamp, + scoreData.bellLamp === "FULL BELL" + ), + starRating: (scoreData, chart) => + isUnranked(chart) + ? 0 + : ONGEKIRating.calculatePlatinum( + chart.levelNum, + starCount(scoreData.platinumScore, chart.data.maxPlatScore) + ), + }, + sessionCalcs: { + naiveRating: SessionAvgBest10For("rating"), + naiveScoreRating: SessionAvgBest10For("scoreRating"), + starRating: SessionAvgBest10For("starRating"), }, - sessionCalcs: { naiveRating: SessionAvgBest10For("rating") }, profileCalcs: { naiveRating: ProfileAvgBestN("rating", 45, false, 100), + naiveRatingRefresh: async (game: Game, playtype: Playtype, userID: integer) => { + const [score, star] = await Promise.all([ + ProfileAvgBestN("scoreRating", 60, false, 1000)(game, playtype, userID), + ProfileAvgBestN("starRating", 50, false, 1000)(game, playtype, userID), + ]); + + const score1k = Math.round((score ?? 0) * 1000); + const star1k = Math.round((star ?? 0) * 1000); + + return (Math.floor(score1k * 1.2) + star1k) / 1000.0; + }, }, classDerivers: { colour: (ratings) => { - const rating = ratings.naiveRating; + const rating = ratings.naiveRatingRefresh; if (IsNullish(rating)) { return null; } - if (rating >= 15) { + if (rating >= 21) { + return "RAINBOW_EX"; + } else if (rating >= 20) { + return "RAINBOW_SHINY"; + } else if (rating >= 19) { return "RAINBOW"; - } else if (rating >= 14.5) { + } else if (rating >= 18) { return "PLATINUM"; - } else if (rating >= 14) { + } else if (rating >= 17) { return "GOLD"; - } else if (rating >= 13) { + } else if (rating >= 15) { return "SILVER"; - } else if (rating >= 12) { + } else if (rating >= 13) { return "COPPER"; - } else if (rating >= 10) { + } else if (rating >= 11) { return "PURPLE"; - } else if (rating >= 7) { + } else if (rating >= 9) { return "RED"; - } else if (rating >= 4) { + } else if (rating >= 7) { return "ORANGE"; - } else if (rating >= 2) { + } else if (rating >= 4) { return "GREEN"; } @@ -91,6 +134,8 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { }, goalCriteriaFormatters: { score: GoalFmtScore, + platinumScore: (val: number) => `Get ${val.toLocaleString("en-GB")} Platinum Score on`, + platinumStars: (val: number) => `Get ${FmtStars(val)} on`, }, goalProgressFormatters: { grade: (pb, gradeIndex) => @@ -103,13 +148,18 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { noteLamp: (pb) => pb.scoreData.noteLamp, bellLamp: (pb) => pb.scoreData.bellLamp, score: (pb) => FmtNum(pb.scoreData.score), + platinumScore: (pb) => FmtNum(pb.scoreData.platinumScore), + platinumStars: (pb) => FmtStarsCompact(pb.scoreData.platinumStars), }, goalOutOfFormatters: { score: GoalOutOfFmtScore, + platinumScore: GoalOutOfFmtScore, + platinumStars: () => FmtStarsCompact(6), }, pbMergeFunctions: [ - CreatePBMergeFor("largest", "optional.platScore", "Best Platinum Score", (base, score) => { - base.scoreData.optional.platScore = score.scoreData.optional.platScore; + CreatePBMergeFor("largest", "platinumScore", "Best Platinum Score", (base, score) => { + base.scoreData.platinumScore = score.scoreData.platinumScore; + base.scoreData.platinumStars = score.scoreData.platinumStars; }), CreatePBMergeFor("largest", "enumIndexes.noteLamp", "Best Note Lamp", (base, score) => { base.scoreData.noteLamp = score.scoreData.noteLamp; @@ -120,15 +170,34 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { ], defaultMergeRefName: "Best Score", scoreValidators: [ - (s) => { + (s, chart) => { let { hit, miss } = s.scoreData.judgements; + let rbreak = s.scoreData.judgements.break; hit ??= 0; miss ??= 0; + rbreak ??= 0; + + if (s.scoreData.noteLamp === "ALL BREAK+") { + if (hit + miss + rbreak > 0) { + return "Cannot have an ALL BREAK+ if not all hits were critical break or better."; + } + + if (s.scoreData.score < 1010000) { + return "Cannot have an ALL BREAK+ if the score is not 1,010,000"; + } + } + + if ( + s.scoreData.score === 1010000 && + (s.scoreData.noteLamp !== "ALL BREAK+" || s.scoreData.bellLamp !== "FULL BELL") + ) { + return "Cannot have a perfect score without FBAB+"; + } if (s.scoreData.noteLamp === "ALL BREAK") { if (hit + miss > 0) { - return "Cannot have an ALL BREAK if not all hits were justice or better."; + return "Cannot have an ALL BREAK if not all hits were break or better."; } } @@ -137,6 +206,14 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = { return "Cannot have a FULL COMBO if the score has misses."; } } + + if (s.scoreData.bellLamp === "FULL BELL" && s.scoreData.noteLamp === "LOSS") { + return "Cannot have a LOSS with a FULL BELL."; + } + + if (s.scoreData.platinumScore > chart.data.maxPlatScore) { + return `Cannot have ${s.scoreData.platinumScore}/${chart.data.maxPlatScore} Platinum Score.`; + } }, ], }; diff --git a/server/src/lib/migration/migrations.ts b/server/src/lib/migration/migrations.ts index 86765a7c0..fca2ae855 100644 --- a/server/src/lib/migration/migrations.ts +++ b/server/src/lib/migration/migrations.ts @@ -10,6 +10,7 @@ import KtRatingToCuratorSkill from "./migrations/ktRating-to-curator-skill"; import MarkFervidexAsWithoutIntent from "./migrations/mark-fervidex-as-without-intent"; import MarkKsHookAsWithoutIntent from "./migrations/mark-kshook-as-without-intent"; import NullLR2HookFailedBPs from "./migrations/null-lr2hook-failed-bps"; +import OngekiV2 from "./migrations/ongeki-v2"; import RemoveAbove100pc from "./migrations/remove-above-100pc"; import RemoveIIDXBeginners from "./migrations/remove-iidx-beginners"; import RemoveIIDX2dxtraBeginners from "./migrations/remove-iidx-extra-beginners"; @@ -73,7 +74,8 @@ if (Environment.nodeEnv !== "test") { KtRatingToCuratorSkill, RemoveIIDX2dxtraBeginners, RmHot, - ChunithmLampSplit + ChunithmLampSplit, + OngekiV2 // SdvxMaxxiveLampRearrange, // SdvxMaxxiveGoals ); diff --git a/server/src/lib/migration/migrations/ongeki-v2.ts b/server/src/lib/migration/migrations/ongeki-v2.ts new file mode 100644 index 000000000..2a019e661 --- /dev/null +++ b/server/src/lib/migration/migrations/ongeki-v2.ts @@ -0,0 +1,69 @@ +import db from "external/mongo/db"; +import { rootLogger } from "lib/logger/logger"; +import UpdateScore from "lib/score-mutation/update-score"; +import { ONGEKI_NOTE_LAMPS, type ScoreDocument } from "tachi-common"; +import { EfficientDBIterate } from "utils/efficient-db-iterate"; +import type { Migration } from "utils/types"; + +const migration: Migration = { + id: "ongeki-v2", + up: async () => { + const failedScores = []; + + await EfficientDBIterate( + db.scores, + (score) => { + return score as ScoreDocument<"ongeki:Single">; + }, + async (scores: Array>) => { + for (const score of scores) { + // @ts-expect-error just incase + delete score._id; + + try { + const newScore = { + ...score, + }; + + if (newScore.scoreData.score === 1010000) { + newScore.scoreData.noteLamp = "ALL BREAK+"; + newScore.scoreData.enumIndexes.noteLamp = + ONGEKI_NOTE_LAMPS.ALL_BREAK_PLUS; + } + + if ( + "platScore" in newScore.scoreData.optional && + typeof newScore.scoreData.optional.platScore === "number" + ) { + newScore.scoreData.platinumScore = + newScore.scoreData.optional.platScore; + delete newScore.scoreData.optional.platScore; + } else { + newScore.scoreData.platinumScore ??= 0; + } + + await UpdateScore(score, newScore); + } catch (err) { + rootLogger.warn(`Error applying ${score.scoreID}:`); + rootLogger.warn(err); + rootLogger.warn("Continuing through the error."); + + failedScores.push(score.scoreID); + } + } + }, + { game: "ongeki" } + ); + + if (failedScores.length > 0) { + throw new Error( + `${failedScores.length} failed to be migrated. Resolve these manually, please.` + ); + } + }, + down: () => { + throw new Error(`Reverting this change is not possible.`); + }, +}; + +export default migration; 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 d3310446a..7af89592f 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 @@ -186,7 +186,7 @@ async function GetSortedPBs(game: Game, playtype: Playtype, chartID: string) { { sort: { [`scoreData.score`]: -1, - [`scoreData.optional.platScore`]: -1, + [`scoreData.platinumScore`]: -1, timeAchieved: 1, }, } diff --git a/server/src/lib/score-import/import-types/api/myt-ongeki/converter.test.ts b/server/src/lib/score-import/import-types/api/myt-ongeki/converter.test.ts index 96c3a036a..d33ded847 100644 --- a/server/src/lib/score-import/import-types/api/myt-ongeki/converter.test.ts +++ b/server/src/lib/score-import/import-types/api/myt-ongeki/converter.test.ts @@ -71,6 +71,7 @@ t.test("#ConvertAPIMytOngeki", (t) => { score: 1003385, noteLamp: "CLEAR", bellLamp: "FULL BELL", + platinumScore: 893, judgements: { cbreak: 967, break: 19, @@ -81,7 +82,6 @@ t.test("#ConvertAPIMytOngeki", (t) => { damage: 0, bellCount: 174, totalBellCount: 174, - platScore: 893, }, }, }, @@ -163,20 +163,6 @@ t.test("#ConvertAPIMytOngeki", (t) => { t.end(); }); - t.test("platinum score should be null if value is 0", async (t) => { - t.hasStrict( - await convert({ - info: { - platinumScore: 0, - }, - }), - { - dryScore: { scoreData: { optional: { platScore: null } } }, - } - ); - t.end(); - }); - t.test("Should reject unspecified clear status", (t) => { t.rejects( () => diff --git a/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts b/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts index 2ce72963a..4edf012a0 100644 --- a/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts +++ b/server/src/lib/score-import/import-types/api/myt-ongeki/converter.ts @@ -28,7 +28,8 @@ const DIFFICULTIES = { function getNoteLamp( comboStatus: number, - clearStatus: number + clearStatus: number, + techScore: number ): ScoreData<"ongeki:Single">["noteLamp"] | undefined { if ( comboStatus === OngekiComboStatus.ONGEKI_COMBO_STATUS_UNSPECIFIED || @@ -37,6 +38,10 @@ function getNoteLamp( return undefined; } + if (techScore === 1010000) { + return "ALL BREAK+"; + } + if (comboStatus === OngekiComboStatus.ONGEKI_COMBO_STATUS_ALL_BREAK) { return "ALL BREAK"; } @@ -77,7 +82,7 @@ const ConvertAPIMytOngeki: ConverterFunction = asyn ); } - const noteLamp = getNoteLamp(data.info.comboStatus, data.info.clearStatus); + const noteLamp = getNoteLamp(data.info.comboStatus, data.info.clearStatus, data.info.techScore); if (noteLamp === undefined) { throw new InvalidScoreFailure( @@ -114,6 +119,7 @@ const ConvertAPIMytOngeki: ConverterFunction = asyn score: data.info.techScore, noteLamp, bellLamp: data.info.isFullBell ? "FULL BELL" : "NONE", + platinumScore: data.info.platinumScore, judgements: { cbreak: data.judge.judgeCriticalBreak, break: data.judge.judgeBreak, @@ -124,10 +130,6 @@ const ConvertAPIMytOngeki: ConverterFunction = asyn damage: data.judge.damageCount, bellCount: data.judge.bellCount, totalBellCount: data.judge.totalBellCount, - // Set platinum score to explicitly null if the value is 0 - // There is no distinction between 0 and null/undefined in gRPC - // Handles playlogs from older versions when Platinum Score didn't exist - platScore: data.info.platinumScore || null, }, }, }; diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 36a7927d8..d5b199e8d 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -1429,12 +1429,14 @@ export const TestingOngekiScorePB: PBScoreDocument<"ongeki:Single"> = { isPrimary: true, scoreData: { score: 1010000, - noteLamp: "ALL BREAK", + noteLamp: "ALL BREAK+", bellLamp: "FULL BELL", + platinumScore: 1000, + platinumStars: 6, grade: "SSS+", enumIndexes: { grade: 11, - noteLamp: 3, + noteLamp: 4, bellLamp: 1, }, judgements: {},