feat: O.N.G.E.K.I. Re:Fresh rating algorithm (#1263)

* feat: Re:Fresh support, rebased for the 6th time

* fix: migration

* fix: seeds

* fix: add tests and make them work

* fix: misc corrections

* rebased

* chore: bump rg-stats

* maybe a better migration

* attempt manual lint

* fix: star formatters

* fix: re-add name overrides

* rename them again, more consistently

* revert rbreak

* finish renaming

* i may be stupid

* refresh insights

* insight fixes

* fix: re-add platinum graphs

* delete score._id

* fix: server test

* fix: tiebreaks

* fix: what

* fix: don't kill p-score if migration is reapplied

I wonder why this could be needed

* fix: maybe notify about any errors

* docs: redefine starRating

* docs: typo

* fix(client): shrink graphs when necessary

* fix(client): use compact starfield in the insight

* feat(client): separate column for hits

* fix(client): add vertical-align

* fix: validate p-score properly

* fix: recolor AB+ to blue, actually

* fix(client): insight readability improvements

* feat(client): nicer insight lamps

* feat(client): OngekiScoreRatingCell

* fix(client): use OngekiScoreRatingCell in insight

---------

Co-authored-by: zk <ktchidev@gmail.com>
This commit is contained in:
nairobi
2025-07-06 19:24:30 +01:00
committed by GitHub
co-authored by zk
parent e445beea7e
commit 0a0fc57134
25 changed files with 1086 additions and 302 deletions
@@ -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<LineSvgProps, "data"> = {
margin: { top: 30, bottom: 50, left: 50, right: 50 },
enableGridX: false,
@@ -224,21 +248,23 @@ export default function GekichumaiScoreChart({
component = (
<ResponsiveLine
{...commonProps}
data={limitScoreGraph(game, data)}
yScale={{ type: "linear", min: 970000, max: 1010000 }}
data={limitScoreGraph(game, data, 970_000)}
yScale={{ type: "linear", min: 970_000, max: 1010_000 }}
yFormat={">-,.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) => (
<ChartTooltip>
{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)}
</ChartTooltip>
)}
/>
@@ -247,7 +273,7 @@ export default function GekichumaiScoreChart({
component = (
<ResponsiveLine
{...commonProps}
data={limitScoreGraph(game, data)}
data={limitScoreGraph(game, data, 990_000)}
yScale={{ type: "linear", min: 990_000, max: 1010_000 }}
yFormat={">-,.0f"}
axisLeft={{
@@ -260,8 +286,10 @@ export default function GekichumaiScoreChart({
areaBaselineValue={990000}
tooltip={(d: PointTooltipProps) => (
<ChartTooltip>
{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)}
</ChartTooltip>
)}
/>
@@ -270,7 +298,7 @@ export default function GekichumaiScoreChart({
component = (
<ResponsiveLine
{...commonProps}
data={limitScoreGraph(game, data)}
data={limitScoreGraph(game, data, 97)}
yScale={{ type: "linear", min: 97, max: 101 }}
yFormat={">-,.4f"}
axisLeft={{
@@ -283,7 +311,9 @@ export default function GekichumaiScoreChart({
areaBaselineValue={97}
tooltip={(d: PointTooltipProps) => (
<ChartTooltip>
{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)}
</ChartTooltip>
)}
@@ -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) => (
<ChartTooltip>
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 = (
<ResponsiveLine
{...commonProps}
data={limitScoreGraph(game, data, starValues[0])}
yScale={{ type: "linear", min: starValues[0], max: 0 }}
yFormat={">-,.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) => (
<ChartTooltip>{platinumTooltip(d, starValues)}</ChartTooltip>
)}
/>
);
}
return (
+5 -2
View File
@@ -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<GPTString, Array<GPTUtility>> = {
"wacca:Single": [],
"maimaidx:Single": [],
"maimai:Single": [],
"ongeki:Single": [ONGEKIBreakdownInsight],
"ongeki:Single": [ONGEKIRefreshBreakdownInsight, ONGEKIClassicBreakdownInsight],
"ddr:SP": [],
"ddr:DP": [],
};
@@ -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 <Loading />;
}
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 (
<Row>
<Col xs={12}>
<Card
header={`${reqUser.username}'s Classic NaiveRating (${(
Math.floor(
flatDataset.reduce(
(a, e) => a + (e.calculatedData.rating ?? 0) * 100,
0
) / flatDataset.length
) / 100
).toFixed(2)})`}
header={`${reqUser.username}'s NaiveRatingClassic: ${classicRating.toFixed(2)}`}
>
<TachiTable
dataset={compoundDataset}
@@ -100,7 +72,143 @@ function Component({ game, playtype, reqUser }: UGPT) {
noTopDisplayStr
noBottomDisplayPager
rowFunction={(pbs) => (
<CompactRow pbs={pbs} game={game} playtype={playtype} />
<CompactRow
pbs={pbs}
game={game}
scoreField={(pb) => FormatMillions(pb.scoreData.score)}
ratingField={(pb) => pb.calculatedData.rating?.toFixed(2) ?? "0.00"}
lampField={(pb) => (
<ShortLamp
noteLamp={pb.scoreData.noteLamp}
bellLamp={pb.scoreData.bellLamp}
grade={pb.scoreData.grade}
/>
)}
/>
)}
/>
</Card>
</Col>
</Row>
);
}
function ComponentRefresh({ game, playtype, reqUser }: UGPT) {
if (game !== "ongeki" || playtype !== "Single") {
return <></>;
}
const query1 = useApiQuery<{
pbs: Array<PBScoreDocument<"ongeki:Single">>;
songs: Array<SongDocument<"ongeki">>;
charts: Array<ChartDocument<"ongeki:Single">>;
}>(`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=scoreRating`);
const query2 = useApiQuery<{
pbs: Array<PBScoreDocument<"ongeki:Single">>;
songs: Array<SongDocument<"ongeki">>;
charts: Array<ChartDocument<"ongeki:Single">>;
}>(`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=starRating`);
if (query1.error) {
return <ApiError error={query1.error} />;
}
if (query2.error) {
return <ApiError error={query2.error} />;
}
if (!query1.data || !query2.data) {
return <Loading />;
}
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 (
<Row>
<Col xs={12}>
<Card
header={`${reqUser.username}'s NaiveRatingRefresh: ${(scoreR1k / 1000).toFixed(
3
)} x 1.2 + ${(starR1k / 1000).toFixed(3)} = ${finalRating}`}
>
<TachiTable
dataset={datasetScore}
headers={[]}
entryName="Errors"
pageLen={100}
noTopDisplayStr
noBottomDisplayPager
rowFunction={(pbs) => (
<CompactRow
pbs={pbs}
game={game}
scoreField={(pb) => FormatMillions(pb.scoreData.score)}
ratingField={(pb) => <OngekiScoreRatingCell score={pb} />}
lampField={(pb) => (
<ShortLamp
noteLamp={pb.scoreData.noteLamp}
bellLamp={pb.scoreData.bellLamp}
grade={pb.scoreData.grade}
/>
)}
/>
)}
/>
<Divider />
<TachiTable
dataset={datasetStar}
headers={[]}
entryName="Errors"
pageLen={100}
noTopDisplayStr
noBottomDisplayPager
rowFunction={(pbs) => (
<CompactRow
pbs={pbs}
game={game}
count={50}
scoreField={(pb, chart) =>
`${FmtNum(pb.scoreData.platinumScore)}/${FmtNum(
chart.data.maxPlatScore
)}`
}
ratingField={(pb) =>
pb.calculatedData.starRating?.toFixed(3) ?? "0.000"
}
lampField={(pb) => (
<>
<StarField
stars={pb.scoreData.platinumStars}
compact={true}
/>
</>
)}
/>
)}
/>
</Card>
@@ -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 (
<tr>
{pbs.map((pb) => {
const index = pb.__related.index;
const chart = pb.__related.chart;
if (count !== undefined && index > count) {
return <></>;
}
return (
<>
<IndexCellCustom index={pb.__related.index} />
<IndexCellCustom index={index} />
<DifficultyCell alwaysShort game={game} chart={chart} />
<CompactCell pb={pb} chart={chart} song={pb.__related.song} />
<CompactCell
pb={pb}
chart={chart}
song={pb.__related.song}
scoreField={scoreField}
ratingField={ratingField}
lampField={lampField}
/>
</>
);
})}
@@ -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 (
<td style={{ width: "300px" }} className={className}>
<div style={{ textAlign: "left" }}>
<div style={{ textAlign: "left" }} className="d-flex flex-column gap-2">
<div>
<GentleLink to={CreateChartLink(chart, "ongeki")}>{song.title}</GentleLink>
<GentleLink to={CreateChartLink(chart, "ongeki")}>
<span style={{ fontSize: "120%" }}>{song.title}</span>
</GentleLink>
</div>
<div
style={{
@@ -159,9 +299,9 @@ function CompactCell({
gap: "5px",
}}
>
<span>{FormatMillions(pb.scoreData.score)}</span>
<span>{pb.calculatedData.rating?.toFixed(2)}</span>
<span>{ShortLamp(pb.scoreData.noteLamp, pb.scoreData.bellLamp)}</span>
<span>{scoreField(pb, chart)}</span>
<strong style={{ fontSize: "105%" }}>{ratingField(pb)}</strong>
<span>{lampField(pb)}</span>
</div>
</div>
</td>
@@ -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 (
<span className="d-flex flex-row gap-1 text-center text-nowrap align-items-center">
<span
className="rounded-circle"
style={{ ...style, backgroundColor: ChangeOpacity(color1, 0.2) }}
>
{text1}
</span>
<span
className="rounded-circle"
style={{ ...style, backgroundColor: ChangeOpacity(color2, 0.2) }}
>
{text2}
</span>
<span
className="rounded-circle"
style={{ ...style, backgroundColor: ChangeOpacity(color3, 0.2) }}
>
{grade}
</span>
</span>
);
}
export const ONGEKIBreakdownInsight: GPTUtility = {
function CreateFlatDataset<T extends GPTString>(data: any, alg: AnyScoreRatingAlg, count: number) {
const flatDataset: PBDataset<T> = [];
const songMap = CreateSongMap(data.songs);
const chartMap = CreateChartMap<T>(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<T extends GPTString>(flatDataset: PBDataset<T>, columns: number) {
const compoundDataset: PBDataset<T>[] = [[]];
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,
};
@@ -0,0 +1,10 @@
import React from "react";
import { COLOUR_SET } from "tachi-common";
export default function OngekiDamageCell({ damage }: { damage: number | null | undefined }) {
return (
<td style={{ verticalAlign: "middle" }}>
<strong style={{ color: COLOUR_SET.red }}>{damage ?? "?"}</strong>
</td>
);
}
@@ -19,7 +19,7 @@ export default function OngekiJudgementCell({
}
return (
<td>
<td style={{ verticalAlign: "middle" }}>
<strong>
<div>
<span style={{ color: COLOUR_SET.gold }}>{judgements.cbreak}</span>-
@@ -27,14 +27,9 @@ export default function OngekiJudgementCell({
<span style={{ color: COLOUR_SET.vibrantBlue }}>{judgements.hit}</span>-
<span style={{ color: COLOUR_SET.gray }}>{judgements.miss}</span>
</div>
<div>
<span style={{ color: COLOUR_SET.vibrantYellow }}>
{score.scoreData.optional.bellCount ?? "?"}/
{score.scoreData.optional.totalBellCount ?? "?"}
</span>
<span style={{ color: COLOUR_SET.red, marginLeft: "0.5em" }}>
{score.scoreData.optional.damage ?? "?"}
</span>
<div style={{ color: COLOUR_SET.vibrantYellow }}>
{score.scoreData.optional.bellCount ?? "?"}/
{score.scoreData.optional.totalBellCount ?? "?"}
</div>
</strong>
</td>
@@ -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 (
<td
style={{
verticalAlign: "middle",
background:
bellLamp === "FULL BELL"
? `linear-gradient(-45deg, ${lowCorner} 0%,${lowCorner} 12%,${low} 12%,${low} 100%)`
@@ -1,34 +1,50 @@
import React from "react";
import { Difficulties, integer } from "tachi-common";
import { FmtStars, FmtStarsCompact, integer } from "tachi-common";
export function StarField({
stars: stars,
compact: compact,
}: {
stars: integer;
compact: boolean;
}) {
if (stars < 6) {
return <>{compact ? FmtStarsCompact(stars) : FmtStars(stars)}</>;
}
return (
<span
style={{
background:
"linear-gradient(30deg, #f0788a 5%, #f48fb1, #9174c2, #79bcf2, #70a173, #f7ff99, #faca7d, #ff9d80, #f0788a 85%)",
color: "transparent",
backgroundClip: "text",
}}
>
★★★★★
</span>
);
}
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 <td>N/A</td>;
}
if (platScore === null || platScore === undefined) {
return <td>Unknown</td>;
}
const percentage = Math.round((platinumScore * 10000.0) / maxPlatScore) / 100.0;
return (
<td>
<strong>MAX-{maxPlatScore - platScore}</strong>
{platScore !== undefined && (
<>
<br />
<small className="text-body-secondary">
[{platScore}/{maxPlatScore}]
</small>
</>
)}
<div className="d-flex flex-column">
<strong>{percentage.toFixed(2)}%</strong>
<StarField stars={stars} compact={false} />
<small className="text-body-secondary">
[{platinumScore}/{maxPlatScore}]
</small>
</div>
</td>
);
}
@@ -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 (
<>
<QuickTooltip
wide
tooltipContent={
<>
<MiniTable headers={["Result", "Rating"]}>
<tr>
<td
style={{
backgroundColor: gradeColor,
}}
>
{FormatMillions(score.scoreData.score)}
</td>
<td>{techRating.toFixed(3)}</td>
</tr>
<tr>
<LampCell
lamp={noteLamp}
colour={
GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].enumColours
.noteLamp[noteLamp]
}
/>
<td>{noteRating.toFixed(3)}</td>
</tr>
<tr>
<LampCell
lamp={bellLamp}
colour={
GPT_CLIENT_IMPLEMENTATIONS["ongeki:Single"].enumColours
.bellLamp[bellLamp]
}
/>
<td>{bellRating.toFixed(3)}</td>
</tr>
<tr>
<td
style={{
backgroundColor: gradeColor,
}}
>
<strong>{score.scoreData.grade}</strong>
</td>
<td>{gradeRating.toFixed(3)}</td>
</tr>
</MiniTable>
</>
}
>
<td>
<div className="underline-on-hover">
{FormatScoreRating(score.game, score.playtype, "scoreRating", ratingValue)}
</div>
</td>
</QuickTooltip>
</>
);
}
@@ -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 <Box message="No charts available" />;
}
@@ -48,6 +50,14 @@ export function OngekiGraphsComponent({
<SelectNav id="Score" value={graph} setValue={setGraph} disabled={!available}>
Score
</SelectNav>
<SelectNav
id="Platinum"
value={graph}
setValue={setGraph}
disabled={!availablePlat}
>
P-Score
</SelectNav>
<SelectNav id="Bells" value={graph} setValue={setGraph} disabled={!available}>
Bells
</SelectNav>
@@ -61,7 +71,7 @@ export function OngekiGraphsComponent({
type={graph}
scoreData={score.scoreData}
song={song}
difficulty={chart.difficulty}
chart={chart}
/>
</div>
</>
@@ -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 (
<GekichumaiScoreChart
height="360px"
mobileHeight="175px"
type={type}
totalBellCount={scoreData.optional.totalBellCount!}
difficulty={difficulty}
maximumAbsoluteValue={
type === "Bells" ? scoreData.optional.totalBellCount! : chart.data.maxPlatScore
}
difficulty={chart.difficulty}
data={[
{
id: type,
+34 -18
View File
@@ -21,7 +21,9 @@ import React from "react";
import OngekiLampCell from "components/tables/cells/OngekiLampCell";
import OngekiPlatinumCell from "components/tables/cells/OngekiPlatinumCell";
import CHUNITHMLampCell from "components/tables/cells/CHUNITHMLampCell";
import { bgc, RAINBOW_EX_GRADIENT, RAINBOW_GRADIENT } from "./games/_util";
import OngekiDamageCell from "components/tables/cells/OngekiDamageCell";
import OngekiScoreRatingCell from "components/tables/cells/OngekiScoreRatingCell";
import { bgc, RAINBOW_EX_GRADIENT, RAINBOW_GRADIENT, RAINBOW_SHINY_GRADIENT } from "./games/_util";
import { BMS_14K_IMPL, BMS_7K_IMPL, PMS_IMPL } from "./games/bms-pms";
import { IIDX_DP_IMPL, IIDX_SP_IMPL } from "./games/iidx";
import { GPTClientImplementation } from "./types";
@@ -772,11 +774,9 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = {
SILVER: bgc("gray", "var(--bs-light)"),
GOLD: bgc("var(--bs-warning)", "var(--bs-dark)"),
PLATINUM: bgc("silver", "var(--bs-dark)"),
RAINBOW: {
background:
"linear-gradient(-45deg, #f0788a, #f48fb1, #9174c2, #79bcf2, #70a173, #f7ff99, #faca7d, #ff9d80, #f0788a)",
color: "var(--bs-dark)",
},
RAINBOW: RAINBOW_GRADIENT,
RAINBOW_SHINY: RAINBOW_SHINY_GRADIENT,
RAINBOW_EX: RAINBOW_EX_GRADIENT,
},
},
enumColours: {
@@ -799,6 +799,7 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = {
CLEAR: COLOUR_SET.green,
"FULL COMBO": COLOUR_SET.gold,
"ALL BREAK": COLOUR_SET.white,
"ALL BREAK+": COLOUR_SET.vibrantBlue,
},
bellLamp: {
NONE: COLOUR_SET.gray,
@@ -815,26 +816,32 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = {
ratingSystems: [],
ratingAlgNameOverrides: {
score: {
rating: "Classic Rating",
rating: "ClassicRating",
},
session: {
rating: "Classic Rating",
naiveRating: "NaiveClassicRating",
},
profile: {
naiveRating: "Classic NaiveRating",
naiveRating: "NaiveRatingClassic",
},
},
scoreHeaders: [
[
"Score",
"Score",
NumericSOV(
(x) => 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")}
/>
<OngekiPlatinumCell
platScore={sc.scoreData.optional.platScore}
platinumScore={sc.scoreData.platinumScore}
maxPlatScore={chart.data.maxPlatScore}
difficulty={chart.difficulty}
stars={sc.scoreData.platinumStars}
/>
<OngekiJudgementCell score={sc} />
<OngekiDamageCell damage={sc.scoreData.optional.damage} />
<OngekiLampCell
noteLamp={sc.scoreData.noteLamp}
bellLamp={sc.scoreData.bellLamp}
@@ -864,7 +872,15 @@ export const GPT_CLIENT_IMPLEMENTATIONS: GPTClientImplementations = {
/>
</>
),
ratingCell: ({ sc, rating }) => <RatingCell score={sc} rating={rating} />,
ratingCell: ({ sc, rating }) => (
<>
{rating === "scoreRating" ? (
<OngekiScoreRatingCell score={sc} />
) : (
<RatingCell score={sc} rating={rating} />
)}
</>
),
},
"arcaea:Touch": ARCAEA_TOUCH_IMPL,
"gitadora:Dora": GITADORA_DORA_IMPL,
+6
View File
@@ -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)",
+55 -19
View File
@@ -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",
+3
View File
@@ -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,
}
+22
View File
@@ -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".
*/
+12 -4
View File
@@ -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
+4
View File
@@ -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
+1 -1
View File
@@ -126,4 +126,4 @@
"node": "20",
"pnpm": "8"
}
}
}
@@ -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<number>) => {
const mockPBs = async (
ratings: Array<number>,
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<ScoreData<"ongeki:Single">>,
@@ -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();
});
+107 -30
View File
@@ -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.`;
}
},
],
};
+3 -1
View File
@@ -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
);
@@ -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<ScoreDocument<"ongeki:Single">>) => {
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;
@@ -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,
},
}
@@ -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(
() =>
@@ -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<MytOngekiScore, EmptyObject> = 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<MytOngekiScore, EmptyObject> = 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<MytOngekiScore, EmptyObject> = 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,
},
},
};
+4 -2
View File
@@ -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: {},