feat: and the last bit, score calcs over

This commit is contained in:
zk
2026-03-28 22:55:09 +00:00
parent 6849c5d6a1
commit d9449c5e32
45 changed files with 280 additions and 493 deletions
@@ -62,7 +62,10 @@ function JsonCell({ label, value }: { label: string; value: unknown }) {
return (
<details>
<summary className="small text-body-secondary">{label}</summary>
<pre className="small mb-0 mt-1 p-2 bg-body-secondary rounded" style={{ maxWidth: "20rem" }}>
<pre
className="small mb-0 mt-1 p-2 bg-body-secondary rounded"
style={{ maxWidth: "20rem" }}
>
{json}
</pre>
</details>
@@ -178,11 +181,15 @@ export default function AdminActionsPage() {
</td>
<td className="small">{action.kind}</td>
<td>
<Badge bg={resultVariant(action.result)}>{action.result}</Badge>
<Badge bg={resultVariant(action.result)}>
{action.result}
</Badge>
</td>
<td>
{action.username ? (
<Link to={`/u/${action.username}`}>{action.username}</Link>
<Link to={`/u/${action.username}`}>
{action.username}
</Link>
) : (
<span className="text-body-secondary">—</span>
)}
@@ -204,7 +211,10 @@ export default function AdminActionsPage() {
{totalPages > 1 && (
<div className="d-flex align-items-center gap-3">
{currentPage > 0 && (
<Link className="btn btn-sm btn-outline-primary" to={buildPageUrl(currentPage - 1)}>
<Link
className="btn btn-sm btn-outline-primary"
to={buildPageUrl(currentPage - 1)}
>
← Prev
</Link>
)}
@@ -212,7 +222,10 @@ export default function AdminActionsPage() {
Page {currentPage + 1} of {totalPages}
</span>
{currentPage < totalPages - 1 && (
<Link className="btn btn-sm btn-outline-primary" to={buildPageUrl(currentPage + 1)}>
<Link
className="btn btn-sm btn-outline-primary"
to={buildPageUrl(currentPage + 1)}
>
Next →
</Link>
)}
@@ -97,7 +97,9 @@ export default function AdminCronJobsPage() {
<div className="mb-2">
<code className="me-2">{task.id}</code>
<code className="text-body-secondary">{task.schedule}</code>
{task.description && <p className="small mb-1 mt-1">{task.description}</p>}
{task.description && (
<p className="small mb-1 mt-1">{task.description}</p>
)}
<div className="small text-body-secondary d-flex flex-wrap gap-3">
<span>
Last scheduled:{" "}
@@ -110,7 +112,8 @@ export default function AdminCronJobsPage() {
</div>
<h3 className="h6">
Recent executions <span className="badge bg-secondary">{execs.length}</span>
Recent executions{" "}
<span className="badge bg-secondary">{execs.length}</span>
</h3>
{execs.length === 0 ? (
<p className="small text-body-secondary">No executions yet.</p>
@@ -140,19 +143,25 @@ export default function AdminCronJobsPage() {
{exec.completed_at ? (
MillisToSince(Date.parse(exec.completed_at))
) : (
<span className="text-warning">In progress</span>
<span className="text-warning">
In progress
</span>
)}
</td>
<td className="font-monospace small">
{durationMs(exec.started_at, exec.completed_at)}
</td>
<td>
<Badge bg={execStatusVariant(exec.status)}>{exec.status}</Badge>
<Badge bg={execStatusVariant(exec.status)}>
{exec.status}
</Badge>
</td>
<td className="small">
{exec.error ? (
<details>
<summary className="text-danger">Error</summary>
<summary className="text-danger">
Error
</summary>
<pre className="small mb-0 mt-1 p-2 bg-danger bg-opacity-10 rounded">
{exec.error}
</pre>
@@ -165,7 +174,9 @@ export default function AdminCronJobsPage() {
</pre>
</details>
) : (
<span className="text-body-secondary">—</span>
<span className="text-body-secondary">
—
</span>
)}
</td>
</tr>
@@ -14,8 +14,8 @@ export default function AdminDestructivePage() {
const [ugptUserId, setUgptUserId] = useState("");
const [ugptGame, setUgptGame] = useState<GameGroup>(TachiConfig.GAMES[0]);
const ugptGameConfig = useMemo(() => GetGameGroupConfig(ugptGame), [ugptGame]);
const [ugptPlaytype, setUgptPlaytype] = useState<string>(() =>
GetGameGroupConfig(TachiConfig.GAMES[0]).playtypes[0],
const [ugptPlaytype, setUgptPlaytype] = useState<string>(
() => GetGameGroupConfig(TachiConfig.GAMES[0]).playtypes[0],
);
const [destroyChartId, setDestroyChartId] = useState("");
@@ -29,7 +29,9 @@ export default function AdminDestructivePage() {
<Row className="g-4">
<Col lg={6}>
<Card className="border-danger">
<Card.Header className="bg-danger bg-opacity-10 text-danger">Delete score</Card.Header>
<Card.Header className="bg-danger bg-opacity-10 text-danger">
Delete score
</Card.Header>
<Card.Body>
<Form.Group className="mb-3" controlId="delete-score">
<Form.Label>Score ID</Form.Label>
@@ -70,7 +72,9 @@ export default function AdminDestructivePage() {
<Col lg={6}>
<Card className="border-danger">
<Card.Header className="bg-danger bg-opacity-10 text-danger">Delete session</Card.Header>
<Card.Header className="bg-danger bg-opacity-10 text-danger">
Delete session
</Card.Header>
<Card.Body>
<Form.Group className="mb-3" controlId="delete-session">
<Form.Label>Session ID</Form.Label>
@@ -194,7 +198,9 @@ export default function AdminDestructivePage() {
<Col lg={6}>
<Card className="border-danger">
<Card.Header className="bg-danger bg-opacity-10 text-danger">Destroy chart</Card.Header>
<Card.Header className="bg-danger bg-opacity-10 text-danger">
Destroy chart
</Card.Header>
<Card.Body>
<Form.Group className="mb-3" controlId="destroy-chart-game">
<Form.Label>Game</Form.Label>
@@ -38,7 +38,10 @@ function PayloadCell({ payload }: { payload: unknown }) {
return (
<details>
<summary className="small text-body-secondary">View payload</summary>
<pre className="small mb-0 mt-1 p-2 bg-body-secondary rounded" style={{ maxWidth: "28rem" }}>
<pre
className="small mb-0 mt-1 p-2 bg-body-secondary rounded"
style={{ maxWidth: "28rem" }}
>
{json}
</pre>
</details>
@@ -115,8 +118,7 @@ export default function AdminJobQueuePage() {
{activeJobs.length > 0 && (
<section>
<h2 className="h5">
Active jobs{" "}
<span className="badge bg-primary">{activeJobs.length}</span>
Active jobs <span className="badge bg-primary">{activeJobs.length}</span>
</h2>
<div className="table-responsive">
<Table hover size="sm" striped>
@@ -144,10 +146,14 @@ export default function AdminJobQueuePage() {
<section>
<h2 className="h5">
All jobs <span className="badge bg-secondary">{jobQueue.total.toLocaleString()}</span>
All jobs{" "}
<span className="badge bg-secondary">{jobQueue.total.toLocaleString()}</span>
</h2>
<Form className="d-flex flex-wrap align-items-end gap-3 mb-3" onSubmit={onFilterSubmit}>
<Form
className="d-flex flex-wrap align-items-end gap-3 mb-3"
onSubmit={onFilterSubmit}
>
<Form.Group>
<Form.Label className="small mb-0">Status</Form.Label>
<Form.Select
@@ -223,7 +229,10 @@ export default function AdminJobQueuePage() {
{totalPages > 1 && (
<div className="d-flex align-items-center gap-3 mt-2">
{currentPage > 0 && (
<Link className="btn btn-sm btn-outline-primary" to={buildPageUrl(currentPage - 1)}>
<Link
className="btn btn-sm btn-outline-primary"
to={buildPageUrl(currentPage - 1)}
>
← Prev
</Link>
)}
@@ -231,7 +240,10 @@ export default function AdminJobQueuePage() {
Page {currentPage + 1} of {totalPages}
</span>
{currentPage < totalPages - 1 && (
<Link className="btn btn-sm btn-outline-primary" to={buildPageUrl(currentPage + 1)}>
<Link
className="btn btn-sm btn-outline-primary"
to={buildPageUrl(currentPage + 1)}
>
Next →
</Link>
)}
@@ -19,9 +19,7 @@ export default function AdminOperationsPage() {
const [supporterUser, setSupporterUser] = useState("");
const announcementGameConfig = announcementGame
? GetGameGroupConfig(announcementGame)
: null;
const announcementGameConfig = announcementGame ? GetGameGroupConfig(announcementGame) : null;
return (
<Row className="g-4">
@@ -74,7 +72,9 @@ export default function AdminOperationsPage() {
<Button
disabled={!announcementTitle.trim()}
onClick={() => {
const body: Record<string, unknown> = { title: announcementTitle.trim() };
const body: Record<string, unknown> = {
title: announcementTitle.trim(),
};
if (announcementGame) {
body.game = announcementGame;
}
@@ -190,7 +190,8 @@ export default function AdminOperationsPage() {
<Card.Header>Reprocess all goals</Card.Header>
<Card.Body>
<p className="text-body-secondary small">
Re-runs goal and quest processing for every user game profile. Heavy operation.
Re-runs goal and quest processing for every user game profile. Heavy
operation.
</p>
<Button
onClick={() =>
@@ -4,15 +4,12 @@ import type {
GPTClassDerivers,
GPTGoalFormatters,
GPTGoalProgressFormatters,
GPTNewCalcs,
GPTNewProfileCalcs,
GPTProfileCalculators,
GPTScoreCalculators,
GPTProfileCalcs,
GPTScoreCalcs,
GPTScoreDeriver,
GPTSessionCalcs,
PBMergeFunction,
PBRankingValuesFunction,
ScoreCalculator,
ScoreValidator,
} from "#game-implementations/types";
@@ -94,9 +91,6 @@ export const IIDXLIKE_PB_RANKING_VALUES: PBRankingValuesFunction<IIDXLikes> = (p
tb5: null,
});
export const VF6Calc: ScoreCalculator<GPTStrings["sdvx" | "usc"]> = (scoreData, chart) =>
Volforce.calculateVF6(scoreData.score, scoreData.lamp, chart.levelNum);
export function VF6ToClass(
vf: number,
): MONGO_SpecificUserGameStats<"sdvx:Single">["classes"]["vfClass"] {
@@ -184,23 +178,17 @@ export function VF6ToClass(
return "SIENNA_I";
}
export const SDVXLIKE_SCORE_CALCS: GPTScoreCalculators<SDVXLikes> = { VF6: VF6Calc };
export const SDVXLIKE_NEW_CALCS: GPTNewCalcs<SDVXLikes> = (scoreData, _derivedData, chart) => ({
export const SDVXLIKE_SCORE_CALCS: GPTScoreCalcs<SDVXLikes> = (scoreData, _derivedData, chart) => ({
VF6: Volforce.calculateVF6(scoreData.score, scoreData.lamp, chart.levelNum),
});
export const SDVXLIKE_PROFILE_CALCS: GPTProfileCalculators<SDVXLikes> = {
VF6: ProfileSumBestN("VF6", 50),
};
export const SDVXLIKE_SESSION_CALCS: GPTSessionCalcs<SDVXLikes> = (arr) => {
const v = SessionAvgBest10For("VF6")(arr);
return { ProfileVF6: v !== null ? v * 50 : null };
};
export const SDVXLIKE_NEW_PROFILE_CALCS: GPTNewProfileCalcs<SDVXLikes> = async (
export const SDVXLIKE_PROFILE_CALCS: GPTProfileCalcs<SDVXLikes> = async (
game,
playtype,
userID,
@@ -262,28 +250,11 @@ export const SDVXLIKE_SCORE_VALIDATORS: Array<ScoreValidator<SDVXLikes>> = [
},
];
export const SGLCalc: ScoreCalculator<GPTStrings["bms" | "pms"]> = (scoreData, chart) => {
const ecValue = chart.data.sglEC ?? 0;
const hcValue = chart.data.sglHC ?? 0;
switch (scoreData.lamp) {
case "FULL COMBO":
case "EX HARD CLEAR":
case "HARD CLEAR":
return Math.max(hcValue, ecValue);
case "CLEAR":
case "EASY CLEAR":
return ecValue;
default:
return 0;
}
};
export const SGL_SESSION_CALCS: GPTSessionCalcs<GPTStrings["bms" | "pms"]> = (arr) => ({
sieglinde: SessionAvgBest10For("sieglinde")(arr),
});
export const SGL_NEW_PROFILE_CALCS: GPTNewProfileCalcs<GPTStrings["bms" | "pms"]> = async (
export const SGL_PROFILE_CALCS: GPTProfileCalcs<GPTStrings["bms" | "pms"]> = async (
game,
playtype,
userID,
@@ -291,7 +262,7 @@ export const SGL_NEW_PROFILE_CALCS: GPTNewProfileCalcs<GPTStrings["bms" | "pms"]
sieglinde: await ProfileAvgBestN("sieglinde", 20)(game, playtype, userID),
});
export const SGL_NEW_CALCS: GPTNewCalcs<GPTStrings["bms" | "pms"]> = (
export const SGL_SCORE_CALCS: GPTScoreCalcs<GPTStrings["bms" | "pms"]> = (
scoreData,
_derivedData,
chart,
@@ -48,8 +48,10 @@ t.test("Arcaea Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: string) =>
t.equal(
ARCAEA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingArcaeaSheriruthFTR)
.grade,
ARCAEA_IMPL.scoreDeriver(
dmf(baseMetrics, { score }) as any,
TestingArcaeaSheriruthFTR,
).grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
@@ -67,7 +69,11 @@ t.test("Arcaea Implementation", (t) => {
t.test("Rating Calc", (t) => {
t.equal(
ARCAEA_IMPL.scoreCalcs.potential(scoreData, TestingArcaeaSheriruthFTR),
ARCAEA_IMPL.scoreCalcs(
scoreData,
ARCAEA_IMPL.scoreDeriver(scoreData, TestingArcaeaSheriruthFTR),
TestingArcaeaSheriruthFTR,
).potential,
11.99,
"Basic potential check",
);
@@ -26,7 +26,7 @@ export const ARCAEA_IMPL: GPTServerImplementation<GPTStrings["arcaea"]> = {
scoreDeriver: (scoreData, _chart) => ({
grade: GetGrade(ARCAEA_GBOUNDARIES, scoreData.score),
}),
newCalcs: (scoreData, _derivedData, chart) => ({
scoreCalcs: (scoreData, _derivedData, chart) => ({
potential: Potential.calculate(scoreData.score, chart.levelNum),
}),
pbRankingValues: (pb) => ({
@@ -37,13 +37,10 @@ export const ARCAEA_IMPL: GPTServerImplementation<GPTStrings["arcaea"]> = {
tb4: null,
tb5: null,
}),
scoreCalcs: {
potential: (scoreData, chart) => Potential.calculate(scoreData.score, chart.levelNum),
},
sessionCalcs: (arr) => ({
naivePotential: SessionAvgBest10For("potential")(arr),
}),
newProfileCalcs: async (game, playtype, userID) => ({
profileCalcs: async (game, playtype, userID) => ({
naivePotential: await ProfileAvgBestN("potential", 30)(game, playtype, userID),
}),
classDerivers: (ratings) => {
@@ -71,9 +68,6 @@ export const ARCAEA_IMPL: GPTServerImplementation<GPTStrings["arcaea"]> = {
return { badge: "BLUE" };
},
profileCalcs: {
naivePotential: ProfileAvgBestN("potential", 30),
},
goalCriteriaFormatters: {
score: GoalFmtScore,
},
@@ -169,16 +169,16 @@ for (const [game, playtype, impl] of [
chartData: Partial<ChartDocumentData[BMSPMS]>,
expected: any,
msg: string,
) =>
t.equal(
impl.scoreCalcs.sieglinde(
dmf(mockScore.scoreData, scoreData),
// @ts-expect-error zzz
dmf(chart, { data: chartData as any }),
),
) => {
const sd = dmf(mockScore.scoreData, scoreData);
const ch = dmf(chart, { data: chartData as any });
return t.equal(
impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).sieglinde,
expected,
msg,
);
};
t.test("null sieglinde", (t) => {
f({ lamp: "FAILED" }, {}, 0, "fails should be worth 0");
@@ -1,7 +1,6 @@
import type { GPTServerImplementation, PBMergeFunction } from "#game-implementations/types";
import { CreatePBMergeFor } from "#game-implementations/utils/pb-merge";
import { ProfileAvgBestN } from "#game-implementations/utils/profile-calc";
import { type GPTStrings, IIDXLIKE_GBOUNDARIES } from "tachi-common";
import {
@@ -12,10 +11,9 @@ import {
IIDXLIKE_SCORE_DERIVER,
IIDXLIKE_SCORE_VALIDATORS,
IIDXLIKE_VALIDATORS,
SGL_NEW_CALCS,
SGL_NEW_PROFILE_CALCS,
SGL_PROFILE_CALCS,
SGL_SCORE_CALCS,
SGL_SESSION_CALCS,
SGLCalc,
} from "./_common";
const BMS_PMS_MERGERS: Array<PBMergeFunction<GPTStrings["bms" | "pms"]>> = [
@@ -40,14 +38,12 @@ const BMS_PMS_MERGERS: Array<PBMergeFunction<GPTStrings["bms" | "pms"]>> = [
const BMS_IMPL: GPTServerImplementation<GPTStrings["bms" | "pms"]> = {
scoreDeriver: IIDXLIKE_SCORE_DERIVER,
newCalcs: SGL_NEW_CALCS,
scoreCalcs: SGL_SCORE_CALCS,
sessionCalcs: SGL_SESSION_CALCS,
newProfileCalcs: SGL_NEW_PROFILE_CALCS,
profileCalcs: SGL_PROFILE_CALCS,
classDerivers: (_ratings) => ({}),
chartSpecificValidators: IIDXLIKE_VALIDATORS,
pbRankingValues: IIDXLIKE_PB_RANKING_VALUES,
scoreCalcs: { sieglinde: SGLCalc },
profileCalcs: { sieglinde: ProfileAvgBestN("sieglinde", 20) },
goalCriteriaFormatters: {
percent: GoalFmtPercent,
score: (v) => `Get a score of ${v} on`,
@@ -42,7 +42,8 @@ t.test("CHUNITHM Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: any) =>
t.equal(
CHUNITHM_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, CHUNITHMBBKKChart).grade,
CHUNITHM_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, CHUNITHMBBKKChart)
.grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
@@ -67,7 +68,11 @@ t.test("CHUNITHM Implementation", (t) => {
t.test("Rating Calc", (t) => {
t.equal(
CHUNITHM_IMPL.scoreCalcs.rating(scoreData, CHUNITHMBBKKChart),
CHUNITHM_IMPL.scoreCalcs(
scoreData,
CHUNITHM_IMPL.scoreDeriver(scoreData, CHUNITHMBBKKChart),
CHUNITHMBBKKChart,
).rating,
4.3,
"Basic rating check",
);
@@ -98,7 +103,7 @@ t.test("CHUNITHM Implementation", (t) => {
t.test("Floating-point edge case", async (t) => {
await mockPBs(Array(50).fill(17.15));
t.equal(await CHUNITHM_IMPL.profileCalcs.naiveRating("chunithm", "Single", 1), 17.15);
t.equal((await CHUNITHM_IMPL.profileCalcs("chunithm", "Single", 1)).naiveRating, 17.15);
t.end();
});
@@ -106,7 +111,7 @@ t.test("CHUNITHM Implementation", (t) => {
t.test("Profile with fewer than 50 scores", async (t) => {
await mockPBs([16, 16, 16, 16]);
t.equal(await CHUNITHM_IMPL.profileCalcs.naiveRating("chunithm", "Single", 1), 1.28);
t.equal((await CHUNITHM_IMPL.profileCalcs("chunithm", "Single", 1)).naiveRating, 1.28);
t.end();
});
@@ -14,7 +14,7 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = {
scoreDeriver: (scoreData, _chart) => ({
grade: GetGrade(CHUNITHM_GBOUNDARIES, scoreData.score),
}),
newCalcs: (scoreData, _derivedData, chart) => ({
scoreCalcs: (scoreData, _derivedData, chart) => ({
rating: CHUNITHMRating.calculate(scoreData.score, chart.levelNum),
}),
pbRankingValues: (pb) => ({
@@ -25,13 +25,10 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = {
tb4: null,
tb5: null,
}),
scoreCalcs: {
rating: (scoreData, chart) => CHUNITHMRating.calculate(scoreData.score, chart.levelNum),
},
sessionCalcs: (arr) => ({
naiveRating: SessionAvgBest10For("rating")(arr),
}),
newProfileCalcs: async (game, playtype, userID) => ({
profileCalcs: async (game, playtype, userID) => ({
naiveRating: await ProfileAvgBestN("rating", 50, false, 100)(game, playtype, userID),
}),
classDerivers: (ratings) => {
@@ -75,7 +72,6 @@ export const CHUNITHM_IMPL: GPTServerImplementation<"chunithm:Single"> = {
return { colour: "BLUE" };
},
profileCalcs: { naiveRating: ProfileAvgBestN("rating", 50, false, 100) },
goalCriteriaFormatters: {
score: GoalFmtScore,
},
@@ -25,7 +25,8 @@ t.test("DDR Implementation", (t) => {
t.test("Grade", (t) => {
const f = (score: number, expected: any) =>
t.equal(
impl.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingDDRSP as any).grade,
impl.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingDDRSP as any)
.grade,
expected,
`A score of ${score} should result in grade=${expected}.`,
);
@@ -119,15 +120,16 @@ t.test("DDR Implementation", (t) => {
chartData: Partial<ChartDocumentData["ddr:DP" | "ddr:SP"]>,
expected: any,
msg: string,
) =>
t.equal(
impl.scoreCalcs.flareSkill(
dmf(TestingDDRSPScore.scoreData, scoreData),
dmf(TestingDDRSP, { data: chartData as any }) as any,
),
) => {
const sd = dmf(TestingDDRSPScore.scoreData, scoreData);
const ch = dmf(TestingDDRSP, { data: chartData as any }) as any;
return t.equal(
impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).flareSkill,
expected,
msg,
);
};
f(
{ grade: "AA", lamp: "CLEAR", optional: { flare: "II", enumIndexes: {} } },
@@ -1,7 +1,7 @@
import type {
GPTGoalFormatters,
GPTGoalProgressFormatters,
GPTNewProfileCalcs,
GPTProfileCalcs,
GPTServerImplementation,
ScoreValidator,
} from "#game-implementations/types";
@@ -11,15 +11,12 @@ import { DDRFlare } from "rg-stats";
import {
DDR_GBOUNDARIES,
FmtNum,
type GameGroup,
GetGrade,
GetSpecificGPTConfig,
type integer,
type MONGO_ChartDocument,
type MONGO_PBScoreDocument,
type MONGO_ScoreDocument,
type MONGO_SongDocument,
type Playtype,
} from "tachi-common";
import { IsNullish } from "../../utils/misc";
@@ -155,11 +152,7 @@ export const DDR_SCORE_VALIDATORS: Array<ScoreValidator<"ddr:DP" | "ddr:SP">> =
},
];
const DDR_CALCULATE_FLARE_SKILL: GPTNewProfileCalcs<"ddr:DP" | "ddr:SP"> = async (
game,
playtype,
userID,
) => {
const DDR_PROFILE_CALCS: GPTProfileCalcs<"ddr:DP" | "ddr:SP"> = async (game, playtype, userID) => {
const sc: Array<PBScoreDocumentWithSong> = await MONGODB_KILL["personal-bests"].aggregate([
{
$match: {
@@ -315,7 +308,7 @@ export const DDR_IMPL: GPTServerImplementation<"ddr:DP" | "ddr:SP"> = {
scoreDeriver: (scoreData, _chart) => ({
grade: scoreData.lamp === "FAILED" ? "E" : GetGrade(DDR_GBOUNDARIES, scoreData.score),
}),
newCalcs: (scoreData, _derivedData, chart) => {
scoreCalcs: (scoreData, _derivedData, chart) => {
if (scoreData.lamp === "FAILED") {
return { flareSkill: 0 };
}
@@ -354,27 +347,6 @@ export const DDR_IMPL: GPTServerImplementation<"ddr:DP" | "ddr:SP"> = {
sessionCalcs: (arr) => ({
flareSkill: SessionAvgBest10For("flareSkill")(arr),
}),
newProfileCalcs: DDR_CALCULATE_FLARE_SKILL,
profileCalcs: {
flareSkill: async (game: GameGroup, playtype: Playtype, userID: integer) =>
(await DDR_CALCULATE_FLARE_SKILL(game as "ddr", playtype as "DP" | "SP", userID))
.flareSkill,
},
scoreCalcs: {
flareSkill: (scoreData, chart) => {
// No flare if the song is failed
if (scoreData.lamp === "FAILED") {
return 0;
}
const flareLevel = scoreData.optional.flare
? GetSpecificGPTConfig("ddr:SP").optionalMetrics.flare.values.indexOf(
scoreData.optional.flare,
)
: 0;
return DDRFlare.calculate(chart.levelNum, flareLevel);
},
},
profileCalcs: DDR_PROFILE_CALCS,
scoreValidators: DDR_SCORE_VALIDATORS,
};
@@ -46,8 +46,10 @@ for (const [playtype, impl] of [
t.test("Grade Deriver", (t) => {
const f = (percent: number, expected: any) =>
t.equal(
impl.scoreDeriver(dmf(baseMetrics, { percent }) as any, TestingGitadoraChart as any)
.grade,
impl.scoreDeriver(
dmf(baseMetrics, { percent }) as any,
TestingGitadoraChart as any,
).grade,
expected,
`A percent of ${percent}% should result in grade=${expected}.`,
);
@@ -76,12 +78,16 @@ for (const [playtype, impl] of [
modifant: Partial<ScoreData<GPTStrings["gitadora"]>>,
expected: any,
msg: string,
) =>
t.equal(
impl.scoreCalcs.skill(dmf(scoreData, modifant), TestingGitadoraChart as any),
) => {
const sd = dmf(scoreData, modifant);
const ch = TestingGitadoraChart as any;
return t.equal(
impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).skill,
expected,
msg,
);
};
f({ percent: 76.57 }, 72.74, "Basic Skill Check");
@@ -14,7 +14,7 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita">
scoreDeriver: (scoreData, _chart) => ({
grade: GetGrade(GITADORA_GBOUNDARIES, scoreData.percent),
}),
newCalcs: (scoreData, _derivedData, chart) => ({
scoreCalcs: (scoreData, _derivedData, chart) => ({
skill: GITADORASkill.calculate(scoreData.percent, chart.levelNum),
}),
pbRankingValues: (pb) => ({
@@ -25,13 +25,10 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita">
tb4: null,
tb5: null,
}),
scoreCalcs: {
skill: (scoreData, chart) => GITADORASkill.calculate(scoreData.percent, chart.levelNum),
},
sessionCalcs: (arr) => ({
skill: SessionAvgBest10For("skill")(arr),
}),
newProfileCalcs: async (game, playtype, userID) => ({
profileCalcs: async (game, playtype, userID) => ({
naiveSkill: await ProfileSumBestN("skill", 50)(game, playtype, userID),
}),
classDerivers: (ratings) => {
@@ -77,7 +74,6 @@ const GITADORA_IMPL: GPTServerImplementation<"gitadora:Dora" | "gitadora:Gita">
return { colour: "WHITE" };
},
profileCalcs: { naiveSkill: ProfileSumBestN("skill", 50) },
goalCriteriaFormatters: {
percent: GoalFmtPercent,
},
@@ -39,7 +39,8 @@ t.test("IIDX Implementation", (t) => {
t.test("Percent", (t) => {
const f = (modifant: Partial<typeof baseMetrics>, expected: any, msg: string) =>
t.equal(
impl.scoreDeriver(dmf(baseMetrics, modifant) as any, Testing511SPA as any).percent,
impl.scoreDeriver(dmf(baseMetrics, modifant) as any, Testing511SPA as any)
.percent,
expected,
msg,
);
@@ -144,15 +145,16 @@ t.test("IIDX Implementation", (t) => {
chartData: Partial<ChartDocumentData["iidx:DP" | "iidx:DP"]>,
expected: any,
msg: string,
) =>
t.equal(
impl.scoreCalcs.BPI(
dmf(TestingIIDXSPScore.scoreData, scoreData),
dmf(Testing511SPA, { data: chartData as any }) as any,
),
) => {
const sd = dmf(TestingIIDXSPScore.scoreData, scoreData);
const ch = dmf(Testing511SPA, { data: chartData as any }) as any;
return t.equal(
impl.scoreCalcs(sd, impl.scoreDeriver(sd, ch), ch).BPI,
expected,
msg,
);
};
f(
{ score: 123 },
@@ -190,15 +192,16 @@ t.test("IIDX Implementation", (t) => {
chartData: Partial<ChartDocumentData["iidx:SP"]>,
expected: any,
msg: string,
) =>
t.equal(
IIDX_SP_IMPL.scoreCalcs.ktLampRating(
dmf(TestingIIDXSPScore.scoreData, scoreData),
dmf(Testing511SPA, { data: chartData as any }),
),
) => {
const sd = dmf(TestingIIDXSPScore.scoreData, scoreData);
const ch = dmf(Testing511SPA, { data: chartData as any });
return t.equal(
IIDX_SP_IMPL.scoreCalcs(sd, IIDX_SP_IMPL.scoreDeriver(sd, ch), ch).ktLampRating,
expected,
msg,
);
};
f({ lamp: "FAILED" }, {}, 0, "fails should be worth 0");
f({ lamp: "ASSIST CLEAR" }, {}, 0, "ac should be worth 0");
@@ -260,15 +263,16 @@ t.test("IIDX Implementation", (t) => {
chartData: Partial<ChartDocumentData["iidx:DP"]>,
expected: any,
msg: string,
) =>
t.equal(
IIDX_DP_IMPL.scoreCalcs.ktLampRating(
dmf(TestingIIDXSPScore.scoreData, scoreData),
dmf(Testing511SPA as any, { data: chartData as any }),
),
) => {
const sd = dmf(TestingIIDXSPScore.scoreData, scoreData);
const ch = dmf(Testing511SPA as any, { data: chartData as any });
return t.equal(
IIDX_DP_IMPL.scoreCalcs(sd, IIDX_DP_IMPL.scoreDeriver(sd, ch), ch).ktLampRating,
expected,
msg,
);
};
f({ lamp: "FAILED" }, {}, 0, "fails should be worth 0");
f({ lamp: "ASSIST CLEAR" }, {}, 0, "ac should be worth 0");
@@ -1,11 +1,10 @@
import type {
GPTGoalFormatters,
GPTGoalProgressFormatters,
GPTNewProfileCalcs,
GPTProfileCalcs,
GPTServerImplementation,
GPTSessionCalcs,
PBMergeFunction,
ScoreCalculator,
ScoreValidator,
} from "#game-implementations/types";
@@ -26,31 +25,12 @@ import {
IIDXLIKE_VALIDATORS,
} from "./_common";
const BPICalc: ScoreCalculator<GPTStrings["iidx"]> = (scoreData, chart) => {
if (chart.data.kaidenAverage === null || chart.data.worldRecord === null) {
return null;
}
return PoyashiBPI.calculate(
scoreData.score,
chart.data.kaidenAverage,
chart.data.worldRecord,
chart.data.notecount * 2,
chart.data.bpiCoefficient,
);
};
const IIDX_SESSION_CALCS: GPTSessionCalcs<"iidx:DP" | "iidx:SP"> = (arr) => ({
BPI: SessionAvgBest10For("BPI")(arr),
ktLampRating: SessionAvgBest10For("ktLampRating")(arr),
});
const IIDX_PROFILE_CALCS: GPTServerImplementation<"iidx:DP" | "iidx:SP">["profileCalcs"] = {
BPI: ProfileAvgBestN("BPI", 20, true),
ktLampRating: ProfileAvgBestN("ktLampRating", 20),
};
const IIDX_NEW_PROFILE_CALCS: GPTNewProfileCalcs<"iidx:DP" | "iidx:SP"> = async (
const IIDX_PROFILE_CALCS: GPTProfileCalcs<"iidx:DP" | "iidx:SP"> = async (
game,
playtype,
userID,
@@ -132,7 +112,7 @@ export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = {
scoreDeriver: IIDXLIKE_SCORE_DERIVER,
chartSpecificValidators: IIDXLIKE_VALIDATORS,
pbRankingValues: IIDXLIKE_PB_RANKING_VALUES,
newCalcs: (scoreData, _derivedData, chart) => {
scoreCalcs: (scoreData, _derivedData, chart) => {
const bpi =
chart.data.kaidenAverage === null || chart.data.worldRecord === null
? null
@@ -173,35 +153,9 @@ export const IIDX_SP_IMPL: GPTServerImplementation<"iidx:SP"> = {
return { BPI: bpi, ktLampRating };
},
scoreCalcs: {
BPI: BPICalc,
ktLampRating: (scoreData, chart) => {
// if chart has no ncValue, use the rating of the chart instead.
const ncValue = chart.data.ncTier?.value ?? chart.levelNum;
// if hc < nc, hcValue = ncValue
// this means you can't lose score from getting a better clear.
// same for exhc < hc.
const hcValue = Math.max(chart.data.hcTier?.value ?? 0, ncValue);
const exhcValue = Math.max(chart.data.exhcTier?.value ?? 0, hcValue);
switch (scoreData.lamp) {
case "FULL COMBO":
case "EX HARD CLEAR":
return exhcValue;
case "HARD CLEAR":
return hcValue;
case "CLEAR":
return ncValue;
default:
return 0;
}
},
},
sessionCalcs: IIDX_SESSION_CALCS,
newProfileCalcs: IIDX_NEW_PROFILE_CALCS,
classDerivers: (_ratings) => ({}),
profileCalcs: IIDX_PROFILE_CALCS,
classDerivers: (_ratings) => ({}),
goalCriteriaFormatters: IIDX_GOAL_FMT,
goalProgressFormatters: IIDX_GOAL_PG_FMT,
goalOutOfFormatters: IIDX_GOAL_OO_FMT,
@@ -214,7 +168,7 @@ export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = {
scoreDeriver: IIDXLIKE_SCORE_DERIVER,
chartSpecificValidators: IIDXLIKE_VALIDATORS,
pbRankingValues: IIDXLIKE_PB_RANKING_VALUES,
newCalcs: (scoreData, _derivedData, chart) => {
scoreCalcs: (scoreData, _derivedData, chart) => {
const bpi =
chart.data.kaidenAverage === null || chart.data.worldRecord === null
? null
@@ -246,28 +200,9 @@ export const IIDX_DP_IMPL: GPTServerImplementation<"iidx:DP"> = {
return { BPI: bpi, ktLampRating };
},
scoreCalcs: {
BPI: BPICalc,
ktLampRating: (scoreData, chart) => {
// if chart has no tier, use the rating of the chart instead.
const ecValue = chart.data.dpTier?.value ?? chart.levelNum;
switch (scoreData.lamp) {
case "FULL COMBO":
case "EX HARD CLEAR":
case "HARD CLEAR":
case "CLEAR":
case "EASY CLEAR":
return ecValue;
default:
return 0;
}
},
},
sessionCalcs: IIDX_SESSION_CALCS,
newProfileCalcs: IIDX_NEW_PROFILE_CALCS,
classDerivers: (_ratings) => ({}),
profileCalcs: IIDX_PROFILE_CALCS,
classDerivers: (_ratings) => ({}),
goalCriteriaFormatters: IIDX_GOAL_FMT,
goalProgressFormatters: IIDX_GOAL_PG_FMT,
goalOutOfFormatters: IIDX_GOAL_OO_FMT,
@@ -19,7 +19,7 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = {
grade:
scoreData.lamp === "FAILED" ? "F" : GetGrade(ITG_GBOUNDARIES, scoreData.scorePercent),
}),
newCalcs: (scoreData, _derivedData, chart) => {
scoreCalcs: (scoreData, _derivedData, chart) => {
const blockRating = scoreData.lamp === "FAILED" ? null : chart.data.rankedLevel;
const diedAtMeasure =
@@ -46,47 +46,10 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = {
tb4: null,
tb5: null,
}),
scoreCalcs: {
blockRating: (scoreData, chart) => {
if (scoreData.lamp === "FAILED") {
return null;
}
return chart.data.rankedLevel;
},
fastest32: (scoreData, chart) => {
const diedAtMeasure =
scoreData.lamp === "FAILED"
? (scoreData.survivedPercent / 100) * chart.data.notesPerMeasure.length
: null;
const fastest32 = ITGHighestUnbroken.calculateFromNPSPerMeasure(
chart.data.npsPerMeasure,
chart.data.notesPerMeasure,
diedAtMeasure,
32, // 32 measures is generally peoples go-to.
);
if (fastest32 === null) {
return null;
}
// To avoid confusing players, we reject highest 32s less than
// 100bpm. Due to how highest32 is calculated, it correctly comes
// to the confusing conclusion that sometimes you technically just hit
// 32 unbroken measures at like 14 BPM. This is confusing to end users,
// so we should hide it.
if (fastest32 < 100) {
return null;
}
return fastest32;
},
},
sessionCalcs: (arr) => ({
blockRating: SessionAvgBestNFor("blockRating", 5)(arr),
}),
newProfileCalcs: async (game, playtype, userID) => {
profileCalcs: async (game, playtype, userID) => {
const [highestBlock, fastest32] = await Promise.all([
ProfileSumBestN("blockRating", 1, true)(game, playtype, userID),
ProfileSumBestN("fastest32", 1, true)(game, playtype, userID),
@@ -95,12 +58,6 @@ export const ITG_STAMINA_IMPL: GPTServerImplementation<"itg:Stamina"> = {
return { highestBlock, fastest32 };
},
classDerivers: (_ratings) => ({}),
profileCalcs: {
// the sum of your 1 best blockrating/fastest32 is basically just
// picking your best blockrating/fastest32. neat.
highestBlock: ProfileSumBestN("blockRating", 1, true),
fastest32: ProfileSumBestN("fastest32", 1, true),
},
goalCriteriaFormatters: {
survivedPercent: (val) => `Survive ${NumToDP(val)}% through`,
@@ -41,7 +41,8 @@ t.test("Jubeat Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: any) =>
t.equal(
JUBEAT_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingJubeatChart).grade,
JUBEAT_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingJubeatChart)
.grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
@@ -116,7 +117,11 @@ t.test("Jubeat Implementation", (t) => {
t.test("Jubility Calc", (t) => {
t.equal(
JUBEAT_IMPL.scoreCalcs.jubility(scoreData, TestingJubeatChart),
JUBEAT_IMPL.scoreCalcs(
scoreData,
JUBEAT_IMPL.scoreDeriver(scoreData, TestingJubeatChart),
TestingJubeatChart,
).jubility,
63.7,
"Basic Jubility Test",
);
@@ -165,7 +165,7 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = {
scoreDeriver: (scoreData, _chart) => ({
grade: GetGrade(JUBEAT_GBOUNDARIES, scoreData.score),
}),
newCalcs: (scoreData, _derivedData, chart) => ({
scoreCalcs: (scoreData, _derivedData, chart) => ({
jubility: Jubility.calculate(scoreData.score, scoreData.musicRate, chart.levelNum),
}),
pbRankingValues: (pb) => ({
@@ -176,14 +176,10 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = {
tb4: null,
tb5: null,
}),
scoreCalcs: {
jubility: (scoreData, chart) =>
Jubility.calculate(scoreData.score, scoreData.musicRate, chart.levelNum),
},
sessionCalcs: (arr) => ({
jubility: SessionAvgBest10For("jubility")(arr),
}),
newProfileCalcs: async (game, playtype, userID) => {
profileCalcs: async (game, playtype, userID) => {
const [jubility, naiveJubility] = await Promise.all([
CalculateJubility(game, playtype, userID),
ProfileSumBestN("jubility", 60)(game, playtype, userID),
@@ -220,10 +216,6 @@ export const JUBEAT_IMPL: GPTServerImplementation<"jubeat:Single"> = {
return { colour: "BLACK" };
},
profileCalcs: {
jubility: CalculateJubility,
naiveJubility: ProfileSumBestN("jubility", 60),
},
goalCriteriaFormatters: {
musicRate: (v) => `Get a music rate of ${v.toFixed(1)}% on`,
score: GoalFmtScore,
@@ -58,7 +58,8 @@ t.test("maimai Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (percent: number, expected: string) =>
t.equal(
MAIMAI_IMPL.scoreDeriver(dmf(baseMetrics, { percent }) as any, TestingMaimaiChart).grade,
MAIMAI_IMPL.scoreDeriver(dmf(baseMetrics, { percent }) as any, TestingMaimaiChart)
.grade,
expected,
`A percent of ${percent} should result in grade=${expected}.`,
);
@@ -83,7 +84,11 @@ t.test("maimai Implementation", (t) => {
t.test("Score Calcs", (t) => {
t.equal(
MAIMAI_IMPL.scoreCalcs.rate(scoreData, TestingMaimaiChart),
MAIMAI_IMPL.scoreCalcs(
scoreData,
MAIMAI_IMPL.scoreDeriver(scoreData, TestingMaimaiChart),
TestingMaimaiChart,
).rate,
14.85,
"Basic rate check",
);
@@ -29,7 +29,7 @@ export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = {
? "SSS+"
: GetGrade(MAIMAI_GBOUNDARIES, scoreData.percent),
}),
newCalcs: (scoreData, _derivedData, chart) => ({
scoreCalcs: (scoreData, _derivedData, chart) => ({
rate: MaimaiRate.calculate(scoreData.percent, chart.data.maxPercent, chart.levelNum),
}),
pbRankingValues: (pb) => ({
@@ -40,14 +40,10 @@ export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = {
tb4: null,
tb5: null,
}),
scoreCalcs: {
rate: (scoreData, chart) =>
MaimaiRate.calculate(scoreData.percent, chart.data.maxPercent, chart.levelNum),
},
sessionCalcs: (arr) => ({
rate: SessionAvgBest10For("rate")(arr),
}),
newProfileCalcs: async (game, playtype, userID) => ({
profileCalcs: async (game, playtype, userID) => ({
naiveRate: await ProfileAvgBestN("rate", 30)(game, playtype, userID),
}),
classDerivers: (ratings) => {
@@ -79,9 +75,6 @@ export const MAIMAI_IMPL: GPTServerImplementation<"maimai:Single"> = {
return { colour: "WHITE" };
},
profileCalcs: {
naiveRate: ProfileAvgBestN("rate", 30),
},
goalCriteriaFormatters: {
percent: GoalFmtPercent,
},
@@ -38,8 +38,10 @@ t.test("Maimai DX Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (percent: number, expected: any) =>
t.equal(
MAIMAIDX_IMPL.scoreDeriver(dmf(baseMetrics, { percent }) as any, TestingMaimaiDXChart)
.grade,
MAIMAIDX_IMPL.scoreDeriver(
dmf(baseMetrics, { percent }) as any,
TestingMaimaiDXChart,
).grade,
expected,
`A percent of ${percent} should result in grade=${expected}.`,
);
@@ -14,7 +14,7 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = {
scoreDeriver: (scoreData, _chart) => ({
grade: GetGrade(MAIMAIDX_GBOUNDARIES, scoreData.percent),
}),
newCalcs: (scoreData, _derivedData, chart) => ({
scoreCalcs: (scoreData, _derivedData, chart) => ({
rate: MaimaiDXRate.calculate(scoreData.percent, chart.levelNum, scoreData.lamp),
}),
pbRankingValues: (pb) => ({
@@ -25,14 +25,10 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = {
tb4: null,
tb5: null,
}),
scoreCalcs: {
rate: (scoreData, chart) =>
MaimaiDXRate.calculate(scoreData.percent, chart.levelNum, scoreData.lamp),
},
sessionCalcs: (arr) => ({
rate: SessionAvgBest10For("rate")(arr),
}),
newProfileCalcs: async (game, playtype, userID) => ({
profileCalcs: async (game, playtype, userID) => ({
naiveRate: await ProfileSumBestN("rate", 50)(game, playtype, userID),
}),
classDerivers: (ratings) => {
@@ -66,9 +62,6 @@ export const MAIMAIDX_IMPL: GPTServerImplementation<"maimaidx:Single"> = {
return { colour: "WHITE" };
},
profileCalcs: {
naiveRate: ProfileSumBestN("rate", 50),
},
goalCriteriaFormatters: {
percent: (v) => GoalFmtPercent(v, 4),
},
@@ -33,7 +33,8 @@ t.test("MUSECA Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: any) =>
t.equal(
MUSECA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingMusecaChart).grade,
MUSECA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingMusecaChart)
.grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
@@ -13,7 +13,7 @@ export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = {
scoreDeriver: (scoreData, _chart) => ({
grade: GetGrade(MUSECA_GBOUNDARIES, scoreData.score),
}),
newCalcs: (scoreData, _derivedData, chart) => ({
scoreCalcs: (scoreData, _derivedData, chart) => ({
curatorSkill: CuratorSkill.calculate(scoreData.score, chart.levelNum),
}),
pbRankingValues: (pb) => ({
@@ -24,19 +24,13 @@ export const MUSECA_IMPL: GPTServerImplementation<"museca:Single"> = {
tb4: null,
tb5: null,
}),
scoreCalcs: {
curatorSkill: (scoreData, chart) => CuratorSkill.calculate(scoreData.score, chart.levelNum),
},
sessionCalcs: (arr) => ({
curatorSkill: SessionAvgBest10For("curatorSkill")(arr),
}),
newProfileCalcs: async (game, playtype, userID) => ({
profileCalcs: async (game, playtype, userID) => ({
curatorSkill: await ProfileSumBestN("curatorSkill", 20)(game, playtype, userID),
}),
classDerivers: (_ratings) => ({}),
profileCalcs: {
curatorSkill: ProfileSumBestN("curatorSkill", 20),
},
goalCriteriaFormatters: {
score: GoalFmtScore,
},
@@ -48,7 +48,8 @@ t.test("ONGEKI Implementation", (t: any) => {
t.test("Grade Deriver", (t: any) => {
const f = (score: number, expected: string) =>
t.equal(
ONGEKI_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingOngekiChart).grade,
ONGEKI_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingOngekiChart)
.grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
@@ -94,20 +95,15 @@ t.test("ONGEKI Implementation", (t: any) => {
});
t.test("Rating Calc", (t: any) => {
t.equal(
ONGEKI_IMPL.scoreCalcs.rating(scoreData, TestingOngekiChart),
12.1,
"Basic classic rating check",
);
const derived = ONGEKI_IMPL.scoreDeriver(scoreData, TestingOngekiChart);
const calcs = ONGEKI_IMPL.scoreCalcs(scoreData, derived, TestingOngekiChart);
t.equal(calcs.rating, 12.1, "Basic classic rating check");
t.equal(calcs.scoreRating, 12.1, "Basic score rating check");
t.equal(
ONGEKI_IMPL.scoreCalcs.scoreRating(scoreData, TestingOngekiChart),
12.1,
"Basic score rating check",
);
t.equal(
ONGEKI_IMPL.scoreCalcs.starRating(scoreData, TestingOngekiChart),
calcs.starRating,
Math.floor(scoreData.platinumStars * (TestingOngekiChart.levelNum * 10) ** 2) /
100000.0,
"Basic star rating check",
@@ -152,16 +148,16 @@ t.test("ONGEKI Implementation", (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("ongeki", "Single", 1)).naiveRating, 16.27);
t.equal(
await ONGEKI_IMPL.profileCalcs.naiveRatingRefresh("ongeki", "Single", 1),
(await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRatingRefresh,
19.525,
);
await mockPBs([1, 1, 0, 0], "starRating");
t.equal(
await ONGEKI_IMPL.profileCalcs.naiveRatingRefresh("ongeki", "Single", 1),
(await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRatingRefresh,
19.565,
);
@@ -172,16 +168,16 @@ t.test("ONGEKI Implementation", (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("ongeki", "Single", 1)).naiveRating, 1.42);
t.equal(
await ONGEKI_IMPL.profileCalcs.naiveRatingRefresh("ongeki", "Single", 1),
(await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRatingRefresh,
1.279,
);
await mockPBs([1, 1, 0, 0], "starRating");
t.equal(
await ONGEKI_IMPL.profileCalcs.naiveRatingRefresh("ongeki", "Single", 1),
(await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRatingRefresh,
1.319,
);
@@ -191,7 +187,10 @@ t.test("ONGEKI Implementation", (t: any) => {
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.equal(
(await ONGEKI_IMPL.profileCalcs("ongeki", "Single", 1)).naiveRatingRefresh,
0.04,
);
t.end();
});
@@ -9,12 +9,9 @@ import {
FmtNum,
FmtStars,
FmtStarsCompact,
type GameGroup,
GetGrade,
type integer,
type MONGO_ChartDocument,
ONGEKI_GBOUNDARIES,
type Playtype,
} from "tachi-common";
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
@@ -67,7 +64,7 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = {
grade: GetGrade(ONGEKI_GBOUNDARIES, scoreData.score),
platinumStars: starCount(scoreData.platinumScore, chart.data.maxPlatScore),
}),
newCalcs: (scoreData, derivedData, chart) => {
scoreCalcs: (scoreData, derivedData, chart) => {
if (isUnranked(chart)) {
return { rating: 0, scoreRating: 0, starRating: 0 };
}
@@ -91,32 +88,12 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = {
tb4: null,
tb5: null,
}),
scoreCalcs: {
rating: (scoreData, chart) =>
isUnranked(chart) ? 0 : ONGEKIRating.calculate(scoreData.score, chart.levelNum),
scoreRating: (scoreData, chart) =>
isUnranked(chart)
? 0
: 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: (arr) => ({
naiveRating: SessionAvgBest10For("rating")(arr),
naiveScoreRating: SessionAvgBest10For("scoreRating")(arr),
starRating: SessionAvgBest10For("starRating")(arr),
}),
newProfileCalcs: async (game, playtype, userID) => {
profileCalcs: async (game, playtype, userID) => {
const [naiveRating, score, star] = await Promise.all([
ProfileAvgBestN("rating", 45, false, 100)(game, playtype, userID),
ProfileAvgBestN("scoreRating", 60, false, 1000)(game, playtype, userID),
@@ -162,20 +139,6 @@ export const ONGEKI_IMPL: GPTServerImplementation<"ongeki:Single"> = {
return { colour: "BLUE" };
},
profileCalcs: {
naiveRating: ProfileAvgBestN("rating", 45, false, 100),
naiveRatingRefresh: async (game: GameGroup, 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;
},
},
goalCriteriaFormatters: {
score: GoalFmtScore,
platinumScore: (val: number) => `Get ${val.toLocaleString("en-GB")} Platinum Score on`,
@@ -41,7 +41,8 @@ t.test("Pop'n Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: any) =>
t.equal(
POPN_9B_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingPopnChart).grade,
POPN_9B_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingPopnChart)
.grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
@@ -46,7 +46,7 @@ export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = {
: GetGrade(POPN_GBOUNDARIES, scoreData.score),
};
},
newCalcs: (scoreData, derivedData, chart) => ({
scoreCalcs: (scoreData, derivedData, chart) => ({
classPoints: PopnClassPoints.calculate(scoreData.score, derivedData.lamp, chart.levelNum),
}),
pbRankingValues: (pb) => ({
@@ -57,18 +57,10 @@ export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = {
tb4: null,
tb5: null,
}),
scoreCalcs: {
classPoints: (scoreData, chart) =>
PopnClassPoints.calculate(
scoreData.score,
PopnClearMedalToLamp(scoreData.clearMedal),
chart.levelNum,
),
},
sessionCalcs: (arr) => ({
classPoints: SessionAvgBest10For("classPoints")(arr),
}),
newProfileCalcs: async (game, playtype, userID) => ({
profileCalcs: async (game, playtype, userID) => ({
naiveClassPoints: await ProfileAvgBestN("classPoints", 20)(game, playtype, userID),
}),
classDerivers: (ratings) => {
@@ -96,9 +88,6 @@ export const POPN_9B_IMPL: GPTServerImplementation<"popn:9B"> = {
return { class: "GOD" };
},
profileCalcs: {
naiveClassPoints: ProfileAvgBestN("classPoints", 20),
},
goalCriteriaFormatters: {
score: GoalFmtScore,
},
@@ -49,7 +49,8 @@ t.test("SDVX Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: any) =>
t.equal(
SDVX_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingSDVXAlbidaChart).grade,
SDVX_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingSDVXAlbidaChart)
.grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
@@ -8,20 +8,18 @@ import {
SDVXLIKE_GOAL_FMT,
SDVXLIKE_GOAL_OO_FMT,
SDVXLIKE_GOAL_PG_FMT,
SDVXLIKE_NEW_CALCS,
SDVXLIKE_SCORE_DERIVER,
SDVXLIKE_NEW_PROFILE_CALCS,
SDVXLIKE_PROFILE_CALCS,
SDVXLIKE_SCORE_CALCS,
SDVXLIKE_SCORE_DERIVER,
SDVXLIKE_SCORE_VALIDATORS,
SDVXLIKE_SESSION_CALCS,
} from "./_common";
export const SDVX_IMPL: GPTServerImplementation<"sdvx:Single"> = {
scoreDeriver: SDVXLIKE_SCORE_DERIVER,
newCalcs: SDVXLIKE_NEW_CALCS,
scoreCalcs: SDVXLIKE_SCORE_CALCS,
sessionCalcs: SDVXLIKE_SESSION_CALCS,
newProfileCalcs: SDVXLIKE_NEW_PROFILE_CALCS,
profileCalcs: SDVXLIKE_PROFILE_CALCS,
classDerivers: SDVXLIKE_CLASS_DERIVERS,
pbRankingValues: (pb) => ({
ranking: pb.scoreData.score,
@@ -45,8 +43,6 @@ export const SDVX_IMPL: GPTServerImplementation<"sdvx:Single"> = {
return true;
},
},
scoreCalcs: SDVXLIKE_SCORE_CALCS,
profileCalcs: SDVXLIKE_PROFILE_CALCS,
goalCriteriaFormatters: SDVXLIKE_GOAL_FMT,
goalProgressFormatters: SDVXLIKE_GOAL_PG_FMT,
goalOutOfFormatters: SDVXLIKE_GOAL_OO_FMT,
@@ -44,7 +44,8 @@ for (const [playtype, impl] of [
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: any) =>
t.equal(
impl.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingUSCChart as any).grade,
impl.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingUSCChart as any)
.grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
@@ -7,21 +7,19 @@ import {
SDVXLIKE_GOAL_FMT,
SDVXLIKE_GOAL_OO_FMT,
SDVXLIKE_GOAL_PG_FMT,
SDVXLIKE_NEW_CALCS,
SDVXLIKE_SCORE_DERIVER,
SDVXLIKE_NEW_PROFILE_CALCS,
SDVXLIKE_PB_MERGERS,
SDVXLIKE_PROFILE_CALCS,
SDVXLIKE_SCORE_CALCS,
SDVXLIKE_SCORE_DERIVER,
SDVXLIKE_SCORE_VALIDATORS,
SDVXLIKE_SESSION_CALCS,
} from "./_common";
const USC_IMPL: GPTServerImplementation<GPTStrings["usc"]> = {
scoreDeriver: SDVXLIKE_SCORE_DERIVER,
newCalcs: SDVXLIKE_NEW_CALCS,
scoreCalcs: SDVXLIKE_SCORE_CALCS,
sessionCalcs: SDVXLIKE_SESSION_CALCS,
newProfileCalcs: SDVXLIKE_NEW_PROFILE_CALCS,
profileCalcs: SDVXLIKE_PROFILE_CALCS,
classDerivers: SDVXLIKE_CLASS_DERIVERS,
pbRankingValues: (pb) => ({
ranking: pb.scoreData.score,
@@ -32,8 +30,6 @@ const USC_IMPL: GPTServerImplementation<GPTStrings["usc"]> = {
tb5: null,
}),
chartSpecificValidators: {},
scoreCalcs: SDVXLIKE_SCORE_CALCS,
profileCalcs: SDVXLIKE_PROFILE_CALCS,
goalCriteriaFormatters: SDVXLIKE_GOAL_FMT,
goalProgressFormatters: SDVXLIKE_GOAL_PG_FMT,
goalOutOfFormatters: SDVXLIKE_GOAL_OO_FMT,
@@ -41,7 +41,8 @@ t.test("WACCA Implementation", (t) => {
t.test("Grade Deriver", (t) => {
const f = (score: number, expected: any) =>
t.equal(
WACCA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingWaccaPupaExp).grade,
WACCA_IMPL.scoreDeriver(dmf(baseMetrics, { score }) as any, TestingWaccaPupaExp)
.grade,
expected,
`A score of ${score.toLocaleString()} should result in grade=${expected}.`,
);
@@ -65,7 +66,11 @@ t.test("WACCA Implementation", (t) => {
t.test("Rating Calc", (t) => {
t.equal(
WACCA_IMPL.scoreCalcs.rate(scoreData, TestingWaccaPupaExp),
WACCA_IMPL.scoreCalcs(
scoreData,
WACCA_IMPL.scoreDeriver(scoreData, TestingWaccaPupaExp),
TestingWaccaPupaExp,
).rate,
41.1,
"Basic rating check",
);
@@ -14,7 +14,7 @@ export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = {
scoreDeriver: (scoreData, _chart) => ({
grade: GetGrade(WACCA_GBOUNDARIES, scoreData.score),
}),
newCalcs: (scoreData, _derivedData, chart) => ({
scoreCalcs: (scoreData, _derivedData, chart) => ({
rate: WACCARate.calculate(scoreData.score, chart.levelNum),
}),
pbRankingValues: (pb) => ({
@@ -25,13 +25,10 @@ export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = {
tb4: null,
tb5: null,
}),
scoreCalcs: {
rate: (scoreData, chart) => WACCARate.calculate(scoreData.score, chart.levelNum),
},
sessionCalcs: (arr) => ({
rate: SessionAvgBest10For("rate")(arr),
}),
newProfileCalcs: async (game, playtype, userID) => ({
profileCalcs: async (game, playtype, userID) => ({
naiveRate: await ProfileSumBestN("rate", 50)(game, playtype, userID),
}),
classDerivers: (ratings) => {
@@ -61,9 +58,6 @@ export const WACCA_IMPL: GPTServerImplementation<"wacca:Single"> = {
return { colour: "ASH" };
},
profileCalcs: {
naiveRate: ProfileSumBestN("rate", 50),
},
goalCriteriaFormatters: {
score: GoalFmtScore,
},
@@ -1,4 +1,3 @@
import type { DryScoreData } from "#lib/score-import/framework/common/types";
import type { MONGO_PBScoreDocumentNoRank } from "#lib/score-import/framework/pb/create-pb-doc";
import type {
ClassConfigs,
@@ -34,32 +33,10 @@ interface ChartDependentMax {
chartDependentMax: true;
}
export type ScoreCalculator<GPT extends GPTString> = (
scoreData: DryScoreData<GPT>,
chart: MONGO_ChartDocument<GPT>,
) => number | null;
export type SessionCalculator<GPT extends GPTString> = (
scoreCalcData: Array<MONGO_ScoreDocument<GPT>["calculatedData"]>,
) => number | null;
/**
* Return a number/null from this players UGPT info. This will involve database queries.
*/
export type ProfileCalculator<GPT extends GPTString> = (
game: GPTStringToGame[GPT],
playtype: GPTStringToPlaytype[GPT],
userID: integer,
) => Promise<number | null>;
export type GPTScoreCalculators<GPT extends GPTString> = {
[S in ScoreRatingAlgorithms[GPT]]: ScoreCalculator<GPT>;
};
export type GPTProfileCalculators<GPT extends GPTString> = {
[S in ProfileRatingAlgorithms[GPT]]: ProfileCalculator<GPT>;
};
export type ClassDeriver<GPT extends GPTString, V extends string> = (
profileRatings: MONGO_SpecificUserGameStats<GPT>["ratings"],
) => V | null | undefined;
@@ -114,9 +91,7 @@ export type GPTScoreDeriver<GPT extends GPTString> = (
chart: MONGO_ChartDocument<GPT>,
) => MongoDerivedMetrics[GPT];
// New-style score calc; just f(scoreData, derivedData, chart) -> calculatedData
// instead of the per-algorithm record above.
export type GPTNewCalcs<GPT extends GPTString> = (
export type GPTScoreCalcs<GPT extends GPTString> = (
scoreData: MONGO_ScoreData<GPT>,
derivedData: MongoDerivedMetrics[GPT],
chart: MONGO_ChartDocument<GPT>,
@@ -127,9 +102,8 @@ export type GPTSessionCalcs<GPT extends GPTString> = (
scoreCalcData: Array<MONGO_ScoreDocument<GPT>["calculatedData"]>,
) => Record<SessionRatingAlgorithms[GPT], number | null>;
// New-style profile calc; just f(game, playtype, userID) -> profileCalcData
// instead of the per-algorithm record above.
export type GPTNewProfileCalcs<GPT extends GPTString> = (
/** Profile ratings from UGPT: async f(game, playtype, userID) -> profile ratings record. */
export type GPTProfileCalcs<GPT extends GPTString> = (
game: GPTStringToGame[GPT],
playtype: GPTStringToPlaytype[GPT],
userID: integer,
@@ -221,13 +195,8 @@ export interface GPTServerImplementation<GPT extends GPTString> {
/**
* How should we compute the score rating algorithms for this game?
*
* New style, simpler function.
*/
newCalcs: GPTNewCalcs<GPT>;
// assorted calculator functions
scoreCalcs: GPTScoreCalculators<GPT>;
scoreCalcs: GPTScoreCalcs<GPT>;
/**
* How should we compute session ratings for this game?
@@ -236,11 +205,8 @@ export interface GPTServerImplementation<GPT extends GPTString> {
/**
* How should we compute profile ratings for this game?
*
* New style, simpler function.
*/
newProfileCalcs: GPTNewProfileCalcs<GPT>;
profileCalcs: GPTProfileCalculators<GPT>;
profileCalcs: GPTProfileCalcs<GPT>;
/**
* For any "derived" classes for this game (i.e. classes that are the function
+1 -4
View File
@@ -46,10 +46,7 @@ describe("cdn (S3 integration)", () => {
it("handles concurrent writes to different keys", async () => {
const a = testKey("concurrent-a.txt");
const b = testKey("concurrent-b.txt");
await Promise.all([
CDNStoreOrOverwrite(a, "alpha"),
CDNStoreOrOverwrite(b, "beta"),
]);
await Promise.all([CDNStoreOrOverwrite(a, "alpha"), CDNStoreOrOverwrite(b, "beta")]);
expect((await CDNRetrieve(a)).toString("utf8")).toBe("alpha");
expect((await CDNRetrieve(b)).toString("utf8")).toBe("beta");
await CDNDelete(a);
+6 -1
View File
@@ -2,7 +2,12 @@ import type { Readable } from "node:stream";
import { log } from "#lib/log/log";
import { ServerConfig } from "#lib/setup/config";
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { buffer as streamToBuffer } from "node:stream/consumers";
const saveLoc = ServerConfig.CDN_CONFIG.SAVE_LOCATION;
@@ -10,23 +10,19 @@ import {
/**
* Calculate profile ratings for this UGPT.
*/
export async function CalculateProfileRatings(
export function CalculateProfileRatings(
game: GameGroup,
playtype: Playtype,
userID: integer,
) {
const calculatedData: Record<string, number | null> = {};
): Promise<MONGO_UserGameStats["ratings"]> {
const gptString = GetGPTString(game, playtype);
// Do this in parallel for performance.
await Promise.all(
Object.entries(GPT_SERVER_IMPLEMENTATIONS[gptString].profileCalcs).map(
async ([key, fn]) => {
calculatedData[key] = await fn(game, playtype, userID);
},
),
);
// Per-GPT `profileCalcs` take `GPTStringToGame<GPT>`; at runtime `game` is correct for `gptString`.
const profileCalcs = GPT_SERVER_IMPLEMENTATIONS[gptString].profileCalcs as (
game: GameGroup,
playtype: Playtype,
userID: integer,
) => Promise<MONGO_UserGameStats["ratings"]>;
return calculatedData as MONGO_UserGameStats["ratings"];
return profileCalcs(game, playtype, userID);
}
@@ -4,6 +4,8 @@ import {
GetGPTString,
type GPTString,
type MONGO_ChartDocument,
type MONGO_ScoreData,
type MongoDerivedMetrics,
} from "tachi-common";
import type { DryScoreData } from "../common/types";
@@ -14,16 +16,25 @@ import type { DryScoreData } from "../common/types";
*/
export function CreateScoreCalcData<GPT extends GPTString>(
game: GameGroup,
dryScoreData: DryScoreData<GPT>,
scoreData: DryScoreData<GPT> | MONGO_ScoreData<GPT>,
chart: MONGO_ChartDocument<GPT>,
) {
const gptString = GetGPTString(game, chart.playtype);
const impl = GPT_SERVER_IMPLEMENTATIONS[gptString];
const calcData: Record<string, number | null> = {};
// Union of per-GPT `scoreDeriver` signatures is not callable with generic `GPT`.
for (const [key, fn] of Object.entries(GPT_SERVER_IMPLEMENTATIONS[gptString].scoreCalcs)) {
calcData[key] = fn(dryScoreData, chart);
}
const derivedData = impl.scoreDeriver(
scoreData as any,
chart as any,
) as MongoDerivedMetrics[GPT];
return calcData;
// Per-GPT `scoreCalcs` take game-specific score/derived types; at runtime inputs match `gptString`.
const scoreCalcs = impl.scoreCalcs as unknown as (
scoreData: MONGO_ScoreData<GPT>,
derivedData: MongoDerivedMetrics[GPT],
chart: MONGO_ChartDocument<GPT>,
) => Record<string, number | null>;
return scoreCalcs(scoreData as MONGO_ScoreData<GPT>, derivedData, chart);
}
@@ -1,4 +1,5 @@
import type { KtLogger } from "#lib/log/log";
import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations";
import {
GetGPTConfig,
@@ -31,7 +31,7 @@ export function HydrateScore(
const scoreData = CreateFullScoreData(gpt, dryScore.scoreData, chart, log);
const calculatedData = CreateScoreCalcData(dryScore.game, dryScore.scoreData, chart);
const calculatedData = CreateScoreCalcData(dryScore.game, scoreData, chart);
const score: MONGO_ScoreDocument = {
...dryScore,
@@ -48,9 +48,7 @@ export async function ensureTestCdnBucket() {
}
if (bucket !== "tachi-public") {
throw new Error(
`TACHI_CDN_SAVE_LOCATION_BUCKET must be "tachi-public"; got "${bucket}".`,
);
throw new Error(`TACHI_CDN_SAVE_LOCATION_BUCKET must be "tachi-public"; got "${bucket}".`);
}
const client = new S3Client({