rework beatleader data tracking
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 34s
Deploy Website / docker (ubuntu-latest) (push) Failing after 32s

This commit is contained in:
Lee 2024-10-22 17:30:14 +01:00
parent fa2ba83c7a
commit f3dee6a7d2
12 changed files with 317 additions and 71 deletions

@ -25,7 +25,10 @@ import { SSRCache } from "@ssr/common/cache";
import { fetchWithCache } from "../common/cache.util"; import { fetchWithCache } from "../common/cache.util";
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player"; import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import { BeatLeaderScoreToken } from "@ssr/common/types/token/beatleader/beatleader-score-token"; import { BeatLeaderScoreToken } from "@ssr/common/types/token/beatleader/beatleader-score-token";
import { AdditionalScoreData, AdditionalScoreDataModel } from "@ssr/common/model/additional-score-data"; import {
AdditionalScoreData,
AdditionalScoreDataModel,
} from "../../../common/src/model/additional-score-data/additional-score-data";
const playerScoresCache = new SSRCache({ const playerScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute ttl: 1000 * 60, // 1 minute
@ -167,22 +170,65 @@ export class ScoreService {
return; return;
} }
// The score has already been tracked, so ignore it.
if (
(await this.getAdditionalScoreData(
playerId,
leaderboard.song.hash,
leaderboard.difficulty.difficultyName,
score.baseScore
)) !== undefined
) {
return;
}
const difficulty = leaderboard.difficulty; const difficulty = leaderboard.difficulty;
const difficultyKey = `${difficulty.difficultyName.replace("Plus", "+")}-${difficulty.modeName}`; const difficultyKey = `${difficulty.difficultyName.replace("Plus", "+")}-${difficulty.modeName}`;
await AdditionalScoreDataModel.create({ const rawScoreImprovement = score.scoreImprovement;
const data = {
playerId: playerId, playerId: playerId,
songHash: leaderboard.song.hash, songHash: leaderboard.song.hash.toUpperCase(),
songDifficulty: difficultyKey, songDifficulty: difficultyKey,
songScore: score.baseScore, songScore: score.baseScore,
bombCuts: score.bombCuts, misses: {
wallsHit: score.wallsHit, misses: score.missedNotes + score.badCuts,
missedNotes: score.missedNotes,
bombCuts: score.bombCuts,
badCuts: score.badCuts,
wallsHit: score.wallsHit,
},
pauses: score.pauses, pauses: score.pauses,
fcAccuracy: score.fcAccuracy * 100, fcAccuracy: score.fcAccuracy * 100,
fullCombo: score.fullCombo,
handAccuracy: { handAccuracy: {
left: score.accLeft, left: score.accLeft,
right: score.accRight, right: score.accRight,
}, },
} as AdditionalScoreData); } as AdditionalScoreData;
if (rawScoreImprovement.score > 0) {
data.scoreImprovement = {
score: rawScoreImprovement.score,
misses: {
misses: rawScoreImprovement.missedNotes + rawScoreImprovement.badCuts,
missedNotes: rawScoreImprovement.missedNotes,
bombCuts: rawScoreImprovement.bombCuts,
badCuts: rawScoreImprovement.badCuts,
wallsHit: rawScoreImprovement.wallsHit,
},
accuracy: rawScoreImprovement.accuracy * 100,
fullCombo:
rawScoreImprovement.missedNotes == 0 &&
rawScoreImprovement.bombCuts == 0 &&
rawScoreImprovement.badCuts == 0 &&
rawScoreImprovement.wallsHit == 0,
handAccuracy: {
left: rawScoreImprovement.accLeft,
right: rawScoreImprovement.accRight,
},
};
}
await AdditionalScoreDataModel.create(data);
console.log( console.log(
`Tracked additional score data for "${scorePlayer.name}"(${playerId}), difficulty: ${difficultyKey}, score: ${score.baseScore}` `Tracked additional score data for "${scorePlayer.name}"(${playerId}), difficulty: ${difficultyKey}, score: ${score.baseScore}`
); );
@ -205,7 +251,7 @@ export class ScoreService {
): Promise<AdditionalScoreData | undefined> { ): Promise<AdditionalScoreData | undefined> {
const additionalData = await AdditionalScoreDataModel.findOne({ const additionalData = await AdditionalScoreDataModel.findOne({
playerId: playerId, playerId: playerId,
songHash: songHash, songHash: songHash.toUpperCase(),
songDifficulty: songDifficulty, songDifficulty: songDifficulty,
songScore: songScore, songScore: songScore,
}); });
@ -232,7 +278,6 @@ export class ScoreService {
sort: string, sort: string,
search?: string search?: string
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> { ): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
console.log("hi");
return fetchWithCache( return fetchWithCache(
playerScoresCache, playerScoresCache,
`player-scores-${leaderboardName}-${id}-${page}-${sort}-${search}`, `player-scores-${leaderboardName}-${id}-${page}-${sort}-${search}`,
@ -275,7 +320,6 @@ export class ScoreService {
`${tokenLeaderboard.difficulty.difficulty}-${tokenLeaderboard.difficulty.gameMode}`, `${tokenLeaderboard.difficulty.difficulty}-${tokenLeaderboard.difficulty.gameMode}`,
score.score score.score
); );
console.log("additionalData", additionalData);
if (additionalData !== undefined) { if (additionalData !== undefined) {
score.additionalData = additionalData; score.additionalData = additionalData;
} }

@ -54,7 +54,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
return { return {
id: token.id, id: token.id,
songHash: token.songHash, songHash: token.songHash.toUpperCase(),
songName: token.songName, songName: token.songName,
songSubName: token.songSubName, songSubName: token.songSubName,
songAuthorName: token.songAuthorName, songAuthorName: token.songAuthorName,

@ -1,5 +1,7 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose"; import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose"; import { Document } from "mongoose";
import { HandAccuracy } from "./hand-accuracy";
import { Misses } from "./misses";
/** /**
* The model for a BeatSaver map. * The model for a BeatSaver map.
@ -47,18 +49,8 @@ export class AdditionalScoreData {
@prop({ required: true, index: true }) @prop({ required: true, index: true })
public songScore!: number; public songScore!: number;
/** // Above data is only so we can fetch it
* The amount of times a bomb was hit. // --------------------------------
*/
@prop({ required: false })
public bombCuts!: number;
/**
* The amount of walls hit in the play.
*/
@prop({ required: false })
public wallsHit!: number;
/** /**
* The amount of pauses in the play. * The amount of pauses in the play.
@ -66,28 +58,61 @@ export class AdditionalScoreData {
@prop({ required: false }) @prop({ required: false })
public pauses!: number; public pauses!: number;
/**
* The miss data for the play.
*/
@prop({ required: false, _id: false })
public misses!: Misses;
/** /**
* The hand accuracy for each hand. * The hand accuracy for each hand.
* @private * @private
*/ */
@prop({ required: false }) @prop({ required: false, _id: false })
public handAccuracy!: { public handAccuracy!: HandAccuracy;
/**
* The left hand accuracy.
*/
left: number;
/**
* The right hand accuracy.
*/
right: number;
};
/** /**
* The full combo accuracy of the play. * The full combo accuracy of the play.
*/ */
@prop({ required: true }) @prop({ required: true })
public fcAccuracy!: number; public fcAccuracy!: number;
/**
* Whether the play was a full combo.
*/
@prop({ required: true })
public fullCombo!: boolean;
/**
* The score improvement.
*/
@prop({ required: false, _id: false })
public scoreImprovement?: {
/**
* The change in the score.
*/
score: number;
/**
* The change in the accuracy.
*/
accuracy: number;
/**
* The change in the misses.
*/
misses: Misses;
/**
* Whether the play was a full combo.
*/
fullCombo: boolean;
/**
* The change in the hand accuracy.
*/
handAccuracy: HandAccuracy;
};
} }
export type AdditionalScoreDataDocument = AdditionalScoreData & Document; export type AdditionalScoreDataDocument = AdditionalScoreData & Document;

@ -0,0 +1,15 @@
import { prop } from "@typegoose/typegoose";
export class HandAccuracy {
/**
* The left hand accuracy.
*/
@prop({ required: true })
left!: number;
/**
* The right hand accuracy.
*/
@prop({ required: true })
right!: number;
}

@ -0,0 +1,33 @@
import { prop } from "@typegoose/typegoose";
export class Misses {
/**
* The amount of misses notes + bad cuts.
*/
@prop({ required: true })
misses!: number;
/**
* The total amount of notes that were missed.
*/
@prop({ required: true })
missedNotes!: number;
/**
* The amount of times a bomb was hit.
*/
@prop({ required: true })
bombCuts!: number;
/**
* The amount of walls hit in the play.
*/
@prop({ required: true })
wallsHit!: number;
/**
* The number of bad cuts.
*/
@prop({ required: true })
badCuts!: number;
}

@ -1,6 +1,6 @@
import { Modifier } from "./modifier"; import { Modifier } from "./modifier";
import { Leaderboards } from "../leaderboard"; import { Leaderboards } from "../leaderboard";
import { AdditionalScoreData } from "../model/additional-score-data"; import { AdditionalScoreData } from "../model/additional-score-data/additional-score-data";
export default interface Score { export default interface Score {
/** /**

@ -0,0 +1,21 @@
import React from "react";
/**
* Renders the change for a stat.
*
* @param change the change
* @param isPp whether the stat is pp
* @param formatValue the function to format the value
*/
export function renderChange(change: number | undefined, isPp: boolean, formatValue: (value: number) => string) {
if (change === 0 || (change && change < 0.01) || change === undefined) {
return null;
}
return (
<p className={`text-sm ${change > 0 ? "text-green-400" : "text-red-400"}`}>
{change > 0 ? "+" : ""}
{`${formatValue(change)}${isPp ? "pp" : ""}`}
</p>
);
}

@ -1,8 +1,9 @@
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils"; import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
import { XMarkIcon } from "@heroicons/react/24/solid"; import { XMarkIcon } from "@heroicons/react/24/solid";
import clsx from "clsx"; import clsx from "clsx";
import Tooltip from "@/components/tooltip";
import { ScoreBadgeProps } from "@/components/score/badges/badge-props"; import { ScoreBadgeProps } from "@/components/score/badges/badge-props";
import { ScoreMissesTooltip } from "@/components/score/score-misses-tooltip";
import { Misses } from "@ssr/common/model/additional-score-data/misses";
type ScoreMissesBadgeProps = ScoreBadgeProps & { type ScoreMissesBadgeProps = ScoreBadgeProps & {
/** /**
@ -12,32 +13,65 @@ type ScoreMissesBadgeProps = ScoreBadgeProps & {
}; };
export default function ScoreMissesBadge({ score, hideXMark }: ScoreMissesBadgeProps) { export default function ScoreMissesBadge({ score, hideXMark }: ScoreMissesBadgeProps) {
const additionalData = score.additionalData;
const scoreImprovement = additionalData?.scoreImprovement;
const misses = additionalData?.misses;
const previousScoreMisses: Misses | undefined = misses &&
additionalData &&
scoreImprovement && {
misses: misses.misses - scoreImprovement.misses.misses,
missedNotes: misses.missedNotes - scoreImprovement.misses.missedNotes,
badCuts: misses.badCuts - scoreImprovement.misses.badCuts,
bombCuts: misses.bombCuts - scoreImprovement.misses.bombCuts,
wallsHit: misses.wallsHit - scoreImprovement.misses.wallsHit,
};
return ( return (
<Tooltip <div className="flex flex-col justify-center items-center">
display={ <ScoreMissesTooltip
<div className="flex flex-col"> missedNotes={score.missedNotes}
{!score.fullCombo ? ( badCuts={score.badCuts}
<> bombCuts={misses?.bombCuts}
<p className="font-semibold">Misses</p> wallsHit={misses?.wallsHit}
<p>Missed Notes: {formatNumberWithCommas(score.missedNotes)}</p> fullCombo={score.fullCombo}
<p>Bad Cuts: {formatNumberWithCommas(score.badCuts)}</p> >
{score.additionalData && ( <div className="flex gap-1 items-center">
<> <p>{score.fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.misses)}</p>
<p>Bomb Cuts: {formatNumberWithCommas(score.additionalData.bombCuts)}</p> {!hideXMark && <XMarkIcon className={clsx("w-5 h-5", score.fullCombo ? "hidden" : "text-red-400")} />}
<p>Wall Hits: {formatNumberWithCommas(score.additionalData.wallsHit)}</p>
</>
)}
</>
) : (
<p>Full Combo</p>
)}
</div> </div>
} </ScoreMissesTooltip>
> {additionalData && previousScoreMisses && scoreImprovement && misses && (
<div className="flex gap-1 items-center justify-center"> <div className="flex gap-2 items-center justify-center">
<p>{score.fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.misses)}</p> <ScoreMissesTooltip
{!hideXMark && <XMarkIcon className={clsx("w-5 h-5", score.fullCombo ? "hidden" : "text-red-400")} />} missedNotes={previousScoreMisses.missedNotes}
</div> badCuts={previousScoreMisses.badCuts}
</Tooltip> bombCuts={previousScoreMisses.bombCuts}
wallsHit={previousScoreMisses.wallsHit}
fullCombo={scoreImprovement.fullCombo}
>
<div className="flex gap-1 items-center">
{previousScoreMisses.missedNotes == 0 ? (
<p className="text-green-400">FC</p>
) : (
formatNumberWithCommas(previousScoreMisses.misses)
)}
</div>
</ScoreMissesTooltip>
<p>-&gt;</p>
<ScoreMissesTooltip
missedNotes={misses.missedNotes}
badCuts={misses.badCuts}
bombCuts={misses.bombCuts}
wallsHit={misses.wallsHit}
fullCombo={additionalData.fullCombo}
>
<div className="flex gap-1 items-center">
{additionalData.fullCombo ? <p className="text-green-400">FC</p> : formatNumberWithCommas(misses.misses)}
</div>
</ScoreMissesTooltip>
</div>
)}
</div>
); );
} }

@ -35,7 +35,7 @@ export default function ScoreButtons({
const { toast } = useToast(); const { toast } = useToast();
return ( return (
<div className={`flex justify-end gap-2 h-[${alwaysSingleLine ? "32" : "64"}px]`}> <div className={`flex justify-end gap-2 items-center`}>
<div <div
className={`flex ${alwaysSingleLine ? "flex-nowrap" : "flex-wrap"} items-center lg:items-start justify-center lg:justify-end gap-1`} className={`flex ${alwaysSingleLine ? "flex-nowrap" : "flex-wrap"} items-center lg:items-start justify-center lg:justify-end gap-1`}
> >

@ -0,0 +1,50 @@
import { formatNumberWithCommas } from "@ssr/common/utils/number-utils";
import Tooltip from "@/components/tooltip";
type ScoreMissesTooltipProps = {
missedNotes: number;
badCuts: number;
bombCuts?: number;
wallsHit?: number;
fullCombo?: boolean;
/**
* The tooltip children
*/
children: React.ReactNode;
};
export function ScoreMissesTooltip({
missedNotes,
badCuts,
bombCuts,
wallsHit,
fullCombo,
children,
}: ScoreMissesTooltipProps) {
return (
<Tooltip
display={
<div className="flex flex-col">
{!fullCombo ? (
<>
<p className="font-semibold">Misses</p>
<p>Missed Notes: {formatNumberWithCommas(missedNotes)}</p>
<p>Bad Cuts: {formatNumberWithCommas(badCuts)}</p>
{bombCuts !== undefined && wallsHit !== undefined && (
<>
<p>Bomb Cuts: {formatNumberWithCommas(bombCuts)}</p>
<p>Wall Hits: {formatNumberWithCommas(wallsHit)}</p>
</>
)}
</>
) : (
<p>Full Combo</p>
)}
</div>
}
>
{children}
</Tooltip>
);
}

@ -7,6 +7,7 @@ import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leade
import ScoreMissesBadge from "@/components/score/badges/score-misses"; import ScoreMissesBadge from "@/components/score/badges/score-misses";
import { Modifier } from "@ssr/common/score/modifier"; import { Modifier } from "@ssr/common/score/modifier";
import { ScoreModifiers } from "@/components/score/score-modifiers"; import { ScoreModifiers } from "@/components/score/score-modifiers";
import { renderChange } from "@/common/change";
const badges: ScoreBadge[] = [ const badges: ScoreBadge[] = [
{ {
@ -48,6 +49,8 @@ const badges: ScoreBadge[] = [
return getScoreBadgeFromAccuracy(acc).color; return getScoreBadgeFromAccuracy(acc).color;
}, },
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => { create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
const scoreImprovement = score.additionalData?.scoreImprovement;
const acc = (score.score / leaderboard.maxScore) * 100; const acc = (score.score / leaderboard.maxScore) * 100;
const fcAccuracy = score.additionalData?.fcAccuracy; const fcAccuracy = score.additionalData?.fcAccuracy;
const scoreBadge = getScoreBadgeFromAccuracy(acc); const scoreBadge = getScoreBadgeFromAccuracy(acc);
@ -83,9 +86,15 @@ const badges: ScoreBadge[] = [
</div> </div>
} }
> >
<p className="cursor-default"> <div className="flex flex-col items-center justify-center cursor-default">
{acc.toFixed(2)}% {modCount > 0 && <ScoreModifiers type="simple" limit={1} score={score} />} <p>
</p> {acc.toFixed(2)}% {modCount > 0 && <ScoreModifiers type="simple" limit={1} score={score} />}
</p>
{scoreImprovement &&
renderChange(scoreImprovement.accuracy, false, num => {
return `${num.toFixed(2)}%`;
})}
</div>
</Tooltip> </Tooltip>
</> </>
); );
@ -94,7 +103,14 @@ const badges: ScoreBadge[] = [
{ {
name: "Score", name: "Score",
create: (score: ScoreSaberScore) => { create: (score: ScoreSaberScore) => {
return `${formatNumberWithCommas(Number(score.score.toFixed(0)))}`; const scoreImprovement = score.additionalData?.scoreImprovement;
return (
<div className="flex flex-col items-center justify-center">
<p>{formatNumberWithCommas(Number(score.score.toFixed(0)))}</p>
{scoreImprovement && renderChange(scoreImprovement.score, false, formatNumberWithCommas)}
</div>
);
}, },
}, },
{ {
@ -104,11 +120,15 @@ const badges: ScoreBadge[] = [
if (!score.additionalData) { if (!score.additionalData) {
return undefined; return undefined;
} }
const { handAccuracy } = score.additionalData; const { handAccuracy } = score.additionalData;
const scoreImprovement = score.additionalData.scoreImprovement;
return ( return (
<Tooltip display={"Left Hand Accuracy"}> <Tooltip display={"Left Hand Accuracy"}>
<p>{handAccuracy.left.toFixed(2)}</p> <div className="flex flex-col items-center justify-center">
<p>{handAccuracy.left.toFixed(2)}</p>
{scoreImprovement && renderChange(scoreImprovement.handAccuracy.left, false, num => num.toFixed(2))}
</div>
</Tooltip> </Tooltip>
); );
}, },
@ -122,9 +142,13 @@ const badges: ScoreBadge[] = [
} }
const { handAccuracy } = score.additionalData; const { handAccuracy } = score.additionalData;
const scoreImprovement = score.additionalData.scoreImprovement;
return ( return (
<Tooltip display={"Right Hand Accuracy"}> <Tooltip display={"Right Hand Accuracy"}>
<p>{handAccuracy.right.toFixed(2)}</p> <div className="flex flex-col items-center justify-center">
<p>{handAccuracy.right.toFixed(2)}</p>
{scoreImprovement && renderChange(scoreImprovement.handAccuracy.right, false, num => num.toFixed(2))}
</div>
</Tooltip> </Tooltip>
); );
}, },
@ -144,7 +168,7 @@ type Props = {
export default function ScoreStats({ score, leaderboard }: Props) { export default function ScoreStats({ score, leaderboard }: Props) {
return ( return (
<div className={`grid grid-cols-3 grid-rows-2 gap-1 ml-0 lg:ml-2 h-[64px]`}> <div className={`grid grid-cols-3 grid-rows-2 gap-1 ml-0 lg:ml-2 `}>
<ScoreBadges badges={badges} score={score} leaderboard={leaderboard} /> <ScoreBadges badges={badges} score={score} leaderboard={leaderboard} />
</div> </div>
); );

@ -21,7 +21,7 @@ export default function StatValue({ name, color, value }: Props) {
return ( return (
<div <div
className={clsx( className={clsx(
"flex min-w-16 gap-2 h-[28px] p-1 items-center justify-center rounded-md text-sm cursor-default", "flex min-w-16 gap-2 h-full p-1 items-center justify-center rounded-md text-sm cursor-default",
color ? color : "bg-accent" color ? color : "bg-accent"
)} )}
style={{ style={{