throwing in the towel

This commit is contained in:
zkldi
2023-01-19 16:39:37 +00:00
parent d2bb76ac29
commit e23e290efc
58 changed files with 1177 additions and 1241 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ const SDVXDans = [
ClassValue("INF", "LV.INF", "Inf. Dan"),
];
const SDVXVFClasses = [
export const SDVXVFClasses = [
ClassValue("SIENNA_I", "Sienna I", "0 - 2.499VF"),
ClassValue("SIENNA_II", "Sienna II", "2.5 - 4.999VF"),
ClassValue("SIENNA_III", "Sienna III", "5.0 - 7.499VF"),
+7 -1
View File
@@ -1,4 +1,5 @@
import { FAST_SLOW_MAXCOMBO } from "./_common";
import { SDVXVFClasses } from "./sdvx";
import { ToDecimalPlaces } from "../config-utils";
import { z } from "zod";
import type { INTERNAL_GAME_CONFIG, GamePTConfig } from "../../types/internals";
@@ -73,7 +74,12 @@ export const USC_CONTROLLER_CONF = {
default: "EXH",
},
classes: {},
classes: {
vfClass: {
type: "DERIVED",
values: SDVXVFClasses,
},
},
orderedJudgements: ["critical", "near", "miss"],
+47 -22
View File
@@ -1,11 +1,14 @@
import type {
AnyClasses,
ChartDocumentData,
Classes,
DerivedMetrics,
Difficulties,
ExtractedClasses,
GPTString,
GPTStringToGame,
GPTStringToPlaytype,
GPTStrings,
Game,
Judgements,
OptionalMetrics,
@@ -42,20 +45,20 @@ export interface ChartFolderLookupDocument {
folderID: string;
}
export interface BaseGoalDocument {
export interface BaseGoalDocument<GPT extends GPTString> {
game: Game;
playtype: Playtype;
name: string;
goalID: string;
criteria: GoalCountCriteria | GoalSingleCriteria;
criteria: GoalCountCriteria<GPT> | GoalSingleCriteria<GPT>;
}
interface GoalCriteria {
key: "scoreData.gradeIndex" | "scoreData.lampIndex" | "scoreData.percent" | "scoreData.score";
interface GoalCriteria<GPT extends GPTString> {
key: keyof DerivedMetrics[GPT] | keyof ProvidedMetrics[GPT];
value: number;
}
export interface GoalSingleCriteria extends GoalCriteria {
export interface GoalSingleCriteria<GPT extends GPTString> extends GoalCriteria<GPT> {
mode: "single";
}
@@ -63,7 +66,7 @@ export interface GoalSingleCriteria extends GoalCriteria {
* Criteria for a score to match this criteria - this is a "count" mode, which means that
* atleast N scores have to match this criteria. This is for things like folders.
*/
export interface GoalCountCriteria extends GoalCriteria {
export interface GoalCountCriteria<GPT extends GPTString> extends GoalCriteria<GPT> {
mode: "absolute" | "proportion";
countNum: number;
}
@@ -71,7 +74,8 @@ export interface GoalCountCriteria extends GoalCriteria {
/**
* Goal Document - Single. A goal document that is only for one specific chart.
*/
export interface GoalDocumentSingle extends BaseGoalDocument {
export interface GoalDocumentSingle<GPT extends GPTString = GPTString>
extends BaseGoalDocument<GPT> {
charts: {
type: "single";
data: string;
@@ -82,7 +86,8 @@ export interface GoalDocumentSingle extends BaseGoalDocument {
* Goal Document - Multi. A goal document whos set of charts is the array of
* chartIDs inside "charts".
*/
export interface GoalDocumentMulti extends BaseGoalDocument {
export interface GoalDocumentMulti<GPT extends GPTString = GPTString>
extends BaseGoalDocument<GPT> {
charts: {
type: "multi";
data: Array<string>;
@@ -93,14 +98,18 @@ export interface GoalDocumentMulti extends BaseGoalDocument {
* Goal Document - Folder. A goal document whos set of charts is derived from
* the folderID inside "charts".
*/
export interface GoalDocumentFolder extends BaseGoalDocument {
export interface GoalDocumentFolder<GPT extends GPTString = GPTString>
extends BaseGoalDocument<GPT> {
charts: {
type: "folder";
data: string;
};
}
export type GoalDocument = GoalDocumentFolder | GoalDocumentMulti | GoalDocumentSingle;
export type GoalDocument<GPT extends GPTString = GPTString> =
| GoalDocumentFolder<GPT>
| GoalDocumentMulti<GPT>
| GoalDocumentSingle<GPT>;
interface BaseInviteCodeDocument {
createdBy: integer;
@@ -164,7 +173,7 @@ export interface ImportDocument {
timeFinished: number;
// Contains an array of GPTString, which dictates what (game:playtype)s were involved in this import.
GPTString: Array<GPTString>;
gptStrings: Array<GPTString>;
importID: string;
scoreIDs: Array<string>;
game: Game;
@@ -313,12 +322,24 @@ export interface UserDocument {
authLevel: UserAuthLevels;
}
export interface UserGameStats<GPT extends GPTString = GPTString> {
export interface SpecificUserGameStats<GPT extends GPTString> {
userID: integer;
game: GPTStringToGame[GPT];
playtype: GPTStringToPlaytype[GPT];
ratings: Partial<Record<ProfileRatingAlgorithms[GPT], number | null>>;
classes: Partial<Classes[GPT]>;
classes: Partial<ExtractedClasses[GPT]>;
}
/**
* GPT agnostic stats for a game. This type is easier to work with than the
* specificUserGameStats one for general cases.
*/
export interface UserGameStats {
userID: integer;
game: Game;
playtype: Playtype;
ratings: Partial<Record<ProfileRatingAlgorithms[GPTString], number | null>>;
classes: AnyClasses;
}
export interface ChartTierlistInfo {
@@ -548,15 +569,19 @@ export interface CGCardInfo {
pin: string;
}
interface BMSCourseInner<GPT extends GPTStrings["bms"], Set extends keyof ExtractedClasses[GPT]> {
set: Set;
playtype: GPTStringToPlaytype[GPT];
value: ExtractedClasses[GPT][Set];
}
/**
* Used to resolve beatoraja IR courses.
*/
export interface BMSCourseDocument {
export interface BMSCourseDocument
extends BMSCourseInner<GPTStrings["bms"], keyof ExtractedClasses[GPTStrings["bms"]]> {
title: string;
md5sums: string;
set: "genocideDan" | "lnDan" | "scratchDan" | "stslDan";
playtype: "7K" | "14K";
value: integer;
}
/**
@@ -610,7 +635,7 @@ export interface UGPTSettingsDocument<GPT extends GPTString = GPTString> {
}
export interface UserGameStatsSnapshotDocument<GPT extends GPTString = GPTString>
extends UserGameStats<GPT> {
extends SpecificUserGameStats<GPT> {
rankings: Record<ProfileRatingAlgorithms[GPT], { ranking: integer | null; outOf: integer }>;
playcount: integer;
timestamp: integer;
@@ -662,16 +687,16 @@ export interface ClassDelta {
game: Game;
set: Classes[GPTString];
playtype: Playtype;
old: integer | null;
new: integer;
old: string | null;
new: string;
}
export interface ClassAchievementDocument<GPT extends GPTString = GPTString> {
game: GPTStringToGame[GPT];
playtype: GPTStringToPlaytype[GPT];
classSet: Classes[GPT];
classOldValue: integer | null;
classValue: integer;
classOldValue: string | null;
classValue: string;
timeAchieved: number;
userID: integer;
}
+4 -4
View File
@@ -21,13 +21,13 @@ export interface ClassInfo<ClassID extends string> {
hoverText?: string;
}
interface BaseClassConfig {
interface BaseClassConfig<V extends string> {
/**
* What are the possible values for this class field?
*
* @note This should be in ascending order.
*/
values: Array<ClassInfo<string>>;
values: Array<ClassInfo<V>>;
}
/**
@@ -42,7 +42,7 @@ interface BaseClassConfig {
* the user is 3rd dan when they've cleared 5th dan in the past, this value will not go
* back down at any point.
*/
export interface ProvidedClassConfig extends BaseClassConfig {
export interface ProvidedClassConfig<V extends string = string> extends BaseClassConfig<V> {
type: "PROVIDED";
}
@@ -56,7 +56,7 @@ export interface ProvidedClassConfig extends BaseClassConfig {
* @note "DERIVED" classes are always downgradable, as they are a function of state
* and might go down at any time for any reason.
*/
export interface DerivedClassConfig extends BaseClassConfig {
export interface DerivedClassConfig<V extends string = string> extends BaseClassConfig<V> {
type: "DERIVED";
}
+7 -4
View File
@@ -1,5 +1,4 @@
import type { GAME_CONFIGS, GAME_PT_CONFIGS } from "../config/config";
import type { integer } from "../types";
import type { MatchTypes } from "./batch-manual";
import type {
ExtractClassValues,
@@ -147,12 +146,16 @@ export type ExtractedClasses = {
[G in GPTString]: ExtractClassValues<typeof GAME_PT_CONFIGS[G]["classes"]>;
};
export type AnyClasses = {
[C in Classes[GPTString]]?: string | null;
};
export type Classes = {
[G in GPTString]: keyof typeof GAME_PT_CONFIGS[G]["classes"];
};
export type ClassRecords<G extends GPTString> = {
[C in Classes[G]]: integer;
export type ClassConfigs = {
[G in GPTString]: typeof GAME_PT_CONFIGS[G]["classes"];
};
export type ChartDocumentData = {
@@ -300,7 +303,7 @@ export interface SpecificGamePTConfig<GPT extends GPTString> {
* or they may be provided by score imports, such as "dans", which cannot be
* derived from a player's scores or profile ratings.
*/
classes: Classes[GPT];
classes: ClassConfigs[GPT];
/**
* What's the default score rating algorithm for this GPT?
+2 -2
View File
@@ -9,8 +9,8 @@ export interface WebhookEventClassUpdateV1 {
content: {
userID: integer;
set: Classes[GPTString];
old: integer | null;
new: integer;
old: string | null;
new: string;
game: Game;
playtype: Playtypes[Game];
};
@@ -0,0 +1,358 @@
import type { ClassDeriver, GPTClassDerivers } from "./types";
import type { ExtractedClasses, GPTString, UserGameStats } from "tachi-common";
function VF6ToClass(vf: number): UserGameStats<"sdvx:Single">["classes"]["vfClass"] {
// jesus christ man
if (vf >= 23) {
return "IMPERIAL_IV";
} else if (vf >= 22) {
return "IMPERIAL_III";
} else if (vf >= 21) {
return "IMPERIAL_II";
} else if (vf >= 20) {
return "IMPERIAL_I";
} else if (vf >= 19.75) {
return "CRIMSON_IV";
} else if (vf > 19.5) {
return "CRIMSON_III";
} else if (vf >= 19.25) {
return "CRIMSON_II";
} else if (vf >= 19) {
return "CRIMSON_I";
} else if (vf >= 18.75) {
return "ELDORA_IV";
} else if (vf >= 18.5) {
return "ELDORA_III";
} else if (vf >= 18.25) {
return "ELDORA_II";
} else if (vf >= 18) {
return "ELDORA_I";
} else if (vf >= 17.75) {
return "ARGENTO_IV";
} else if (vf >= 17.5) {
return "ARGENTO_III";
} else if (vf >= 17.25) {
return "ARGENTO_II";
} else if (vf >= 17) {
return "ARGENTO_I";
} else if (vf >= 16.75) {
return "CORAL_IV";
} else if (vf >= 16.5) {
return "CORAL_III";
} else if (vf >= 16.25) {
return "CORAL_II";
} else if (vf >= 16) {
return "CORAL_I";
} else if (vf >= 15.75) {
return "SCARLET_IV";
} else if (vf >= 15.5) {
return "SCARLET_III";
} else if (vf >= 15.25) {
return "SCARLET_II";
} else if (vf >= 15) {
return "SCARLET_I";
} else if (vf >= 14.75) {
return "CYAN_IV";
} else if (vf >= 14.5) {
return "CYAN_III";
} else if (vf >= 14.25) {
return "CYAN_II";
} else if (vf >= 14) {
return "CYAN_I";
} else if (vf >= 13.5) {
return "DANDELION_IV";
} else if (vf >= 13) {
return "DANDELION_III";
} else if (vf >= 12.5) {
return "DANDELION_II";
} else if (vf >= 12) {
return "DANDELION_I";
} else if (vf >= 11.5) {
return "COBALT_IV";
} else if (vf >= 11) {
return "COBALT_III";
} else if (vf >= 10.5) {
return "COBALT_II";
} else if (vf >= 10) {
return "COBALT_I";
} else if (vf >= 7.5) {
return "SIENNA_IV";
} else if (vf >= 5) {
return "SIENNA_III";
} else if (vf >= 2.5) {
return "SIENNA_II";
}
return "SIENNA_I";
}
function GitadoraSkillToColour(sk: number) {
if (sk >= 8500) {
return "RAINBOW";
} else if (sk >= 8000) {
return "GOLD";
} else if (sk >= 7500) {
return "SILVER";
} else if (sk >= 7000) {
return "BRONZE";
} else if (sk >= 6500) {
return "RED_GRD";
} else if (sk >= 6000) {
return "RED";
} else if (sk >= 5500) {
return "PURPLE_GRD";
} else if (sk >= 5000) {
return "PURPLE";
} else if (sk >= 4500) {
return "BLUE_GRD";
} else if (sk >= 4000) {
return "BLUE";
} else if (sk >= 3500) {
return "GREEN_GRD";
} else if (sk >= 3000) {
return "GREEN";
} else if (sk >= 2500) {
return "YELLOW_GRD";
} else if (sk >= 2000) {
return "YELLOW";
} else if (sk >= 1500) {
return "ORANGE_GRD";
} else if (sk >= 1000) {
return "ORANGE";
}
return "WHITE";
}
export const CLASS_DERIVERS: GPTClassDerivers = {
"bms:14K": {},
"bms:7K": {},
"iidx:DP": {},
"iidx:SP": {},
"itg:Stamina": {},
"museca:Single": {},
"pms:Controller": {},
"pms:Keyboard": {},
"chunithm:Single": {
colour: (ratings) => {
const rating = ratings.naiveRating;
if (rating === null) {
return null;
}
if (rating >= 15) {
return "RAINBOW";
} else if (rating >= 14.5) {
return "PLATINUM";
} else if (rating >= 14) {
return "GOLD";
} else if (rating >= 13) {
return "SILVER";
} else if (rating >= 12) {
return "COPPER";
} else if (rating >= 10) {
return "PURPLE";
} else if (rating >= 7) {
return "RED";
} else if (rating >= 4) {
return "ORANGE";
} else if (rating >= 2) {
return "GREEN";
}
return "BLUE";
},
},
"gitadora:Dora": {
colour: (ratings) => {
if (ratings.skill === null) {
return null;
}
return GitadoraSkillToColour(ratings.skill);
},
},
"gitadora:Gita": {
colour: (ratings) => {
if (ratings.skill === null) {
return null;
}
return GitadoraSkillToColour(ratings.skill);
},
},
"jubeat:Single": {
colour: (ratings) => {
const jubility = ratings.jubility;
if (jubility === null) {
return null;
}
if (jubility >= 9500) {
return "GOLD";
} else if (jubility >= 8500) {
return "ORANGE";
} else if (jubility >= 7000) {
return "PINK";
} else if (jubility >= 5500) {
return "PURPLE";
} else if (jubility >= 4000) {
return "VIOLET";
} else if (jubility >= 2500) {
return "BLUE";
} else if (jubility >= 1500) {
return "LIGHT_BLUE";
} else if (jubility >= 750) {
return "GREEN";
} else if (jubility >= 250) {
return "YELLOW_GREEN";
}
return "BLACK";
},
},
"maimaidx:Single": {
colour: (ratings) => {
const rate = ratings.rate;
if (rate === null) {
return null;
}
if (rate >= 15000) {
return "RAINBOW";
} else if (rate >= 14500) {
return "PLATINUM";
} else if (rate >= 14000) {
return "GOLD";
} else if (rate >= 13000) {
return "SILVER";
} else if (rate >= 12000) {
return "BRONZE";
} else if (rate >= 10000) {
return "PURPLE";
} else if (rate >= 7000) {
return "RED";
} else if (rate >= 4000) {
return "YELLOW";
} else if (rate >= 2000) {
return "GREEN";
} else if (rate >= 1000) {
return "BLUE";
}
return "WHITE";
},
},
"popn:9B": {
class: (ratings) => {
const points = ratings.naiveClassPoints;
if (points === null) {
return null;
}
if (points < 21) {
return "KITTY";
} else if (points < 34) {
return "STUDENT";
} else if (points < 46) {
return "DELINQUENT";
} else if (points < 59) {
return "DETECTIVE";
} else if (points < 68) {
return "IDOL";
} else if (points < 79) {
return "GENERAL";
} else if (points < 91) {
return "HERMIT";
}
return "GOD";
},
},
"sdvx:Single": {
vfClass: (ratings) => {
const vf6 = ratings.VF6;
if (vf6 === null) {
return null;
}
return VF6ToClass(vf6);
},
},
"usc:Controller": {
vfClass: (ratings) => {
const vf6 = ratings.VF6;
if (vf6 === null) {
return null;
}
return VF6ToClass(vf6);
},
},
"usc:Keyboard": {
vfClass: (ratings) => {
const vf6 = ratings.VF6;
if (vf6 === null) {
return null;
}
return VF6ToClass(vf6);
},
},
"wacca:Single": {
colour: (ratings) => {
const rate = ratings.rate;
if (rate === null) {
return null;
}
if (rate >= 2500) {
return "RAINBOW";
} else if (rate >= 2200) {
return "GOLD";
} else if (rate >= 1900) {
return "SILVER";
} else if (rate >= 1600) {
return "BLUE";
} else if (rate >= 1300) {
return "PURPLE";
} else if (rate >= 1000) {
return "RED";
} else if (rate >= 600) {
return "YELLOW";
} else if (rate >= 300) {
return "NAVY";
}
return "ASH";
},
},
};
export function CalculateDerivedClasses<GPT extends GPTString>(
gptString: GPT,
profileRatings: UserGameStats<GPT>["ratings"]
) {
const derivedClasses: Record<string, string> = {};
for (const [key, fn] of Object.entries(CLASS_DERIVERS[gptString])) {
const deriver = fn as ClassDeriver<GPT, any>;
derivedClasses[key] = deriver(profileRatings);
}
return derivedClasses as Partial<ExtractedClasses[GPT]>;
}
@@ -0,0 +1,460 @@
import db from "external/mongo/db";
import { GetGPTString } from "tachi-common";
import type { GPTProfileCalculators } from "./types";
import type {
GPTString,
Game,
PBScoreDocument,
Playtype,
ScoreRatingAlgorithms,
Versions,
integer,
UserGameStats,
} from "tachi-common";
/**
* Curries a function that returns the sum of N best ratings on `key`.
*
* @param key - What rating value to sum.
* @param n - The amount of rating values to pull.
* @param returnMean - Optionally, if true, return the sum of these values divided by N.
* @param nullIfNotEnoughScores - If true, return null if the total scores this user has is less than N.
*
* @returns - Number if the user has scores with that rating algorithm, null if they have
* no scores with this rating algorithm that are non-null.
*/
function CalcN<GPT extends GPTString>(
key: ScoreRatingAlgorithms[GPT],
n: integer,
returnMean = false,
nullIfNotEnoughScores = false
) {
return async (game: Game, playtype: Playtype, userID: integer) => {
const sc = await db["personal-bests"].find(
{
game,
playtype,
userID,
isPrimary: true,
// @ts-expect-error what? this can't be a symbol.
[`calculatedData.${key}`]: { $type: "number" },
},
{
limit: n,
// @ts-expect-error what? this can't be a symbol.
sort: { [`calculatedData.${key}`]: -1 },
}
);
if (sc.length === 0) {
return null;
}
if (nullIfNotEnoughScores && sc.length < n) {
return null;
}
let result = sc.reduce((a, e) => a + e.calculatedData[key]!, 0);
if (returnMean) {
result = result / n;
}
return result;
};
}
function SumBestN<GPT extends GPTString>(
key: ScoreRatingAlgorithms[GPT],
n: integer,
nullIfNotEnoughScores = false
) {
return CalcN(key, n, false, nullIfNotEnoughScores);
}
function AvgBestN<GPT extends GPTString>(
key: ScoreRatingAlgorithms[GPT],
n: integer,
nullIfNotEnoughScores = false
) {
return CalcN(key, n, true, nullIfNotEnoughScores);
}
// Wacca has a funny algorithm for rate involving gitadora-style latest chart bonuses,
async function CalculateWACCARate(game: Game, playtype: Playtype, userID: integer) {
const hotChartIDs = (
await db.charts.wacca.find({ "data.isHot": true }, { projection: { chartID: 1 } })
).map((e) => e.chartID);
const coldChartIDs = (
await db.charts.wacca.find({ "data.isHot": false }, { projection: { chartID: 1 } })
).map((e) => e.chartID);
const best15Hot = await db["personal-bests"].find(
{
game,
playtype,
userID,
chartID: { $in: hotChartIDs },
"calculatedData.rate": { $type: "number" },
},
{
sort: {
"calculatedData.rate": -1,
},
limit: 15,
projection: {
"calculatedData.rate": 1,
},
}
);
const best35Cold = await db["personal-bests"].find(
{
game,
playtype,
userID,
chartID: { $in: coldChartIDs },
"calculatedData.rate": { $type: "number" },
},
{
sort: {
"calculatedData.rate": -1,
},
limit: 35,
projection: {
"calculatedData.rate": 1,
},
}
);
if (best15Hot.length + best35Cold.length === 0) {
return null;
}
return (
best15Hot.reduce((a, e) => a + e.calculatedData.rate!, 0) +
best35Cold.reduce((a, e) => a + e.calculatedData.rate!, 0)
);
}
// basically the same as WACCA's.
async function CalculateMaimaiDXRate(game: Game, playtype: Playtype, userID: integer) {
const newChartIDs = (
await db.charts.maimaidx.find({ "data.isLatest": true }, { projection: { chartID: 1 } })
).map((e) => e.chartID);
const oldChartIDs = (
await db.charts.maimaidx.find({ "data.isLatest": false }, { projection: { chartID: 1 } })
).map((e) => e.chartID);
const best15New = await db["personal-bests"].find(
{
game,
playtype,
userID,
chartID: { $in: newChartIDs },
"calculatedData.rate": { $type: "number" },
},
{
sort: {
"calculatedData.rate": -1,
},
limit: 15,
projection: {
"calculatedData.rate": 1,
},
}
);
const best35Old = await db["personal-bests"].find(
{
game,
playtype,
userID,
chartID: { $in: oldChartIDs },
"calculatedData.rate": { $type: "number" },
},
{
sort: {
"calculatedData.rate": -1,
},
limit: 35,
projection: {
"calculatedData.rate": 1,
},
}
);
if (best15New.length + best35Old.length === 0) {
return null;
}
return (
best15New.reduce((a, e) => a + e.calculatedData.rate!, 0) +
best35Old.reduce((a, e) => a + e.calculatedData.rate!, 0)
);
}
async function GetBestRatingOnSongs(
songIDs: Array<integer>,
userID: integer,
game: Game,
playtype: Playtype,
ratingProp: "skill",
limit: integer
): Promise<Array<PBScoreDocument>> {
const r: Array<{ doc: PBScoreDocument }> = await db["personal-bests"].aggregate([
{
$match: {
game,
playtype,
userID,
songID: { $in: songIDs },
},
},
{
$sort: {
[`calculatedData.${ratingProp}`]: -1,
},
},
{
$group: {
_id: "$songID",
doc: { $first: "$$ROOT" },
},
},
// for some godforsaken reason you have to sort twice. after a grouping
// the sort order becomes nondeterministic
{
$sort: {
[`doc.calculatedData.${ratingProp}`]: -1,
},
},
{
$limit: limit,
},
]);
return r.map((e) => e.doc);
}
async function GetBestJubilityOnSongs(
songIDs: Array<integer>,
userID: integer,
game: Game,
playtype: Playtype,
limit: integer
): Promise<Array<PBScoreDocument>> {
const r: Array<{ doc: PBScoreDocument }> = await db["personal-bests"].aggregate([
{
$match: {
game,
playtype,
userID,
songID: { $in: songIDs },
},
},
{
// we need to do stuff dependent on the chart difficulty,
// so we need the chart difficulty
$lookup: {
from: "charts-jubeat",
localField: "chartID",
foreignField: "chartID",
as: "chart",
},
},
{
// "chart" is an array unless we unwind it.
$unwind: {
path: "$chart",
},
},
{
// sort on jubility (so we get the best score)
$sort: {
[`calculatedData.jubility`]: -1,
},
},
{
$group: {
_id: {
songID: "$songID",
// Jubility is unique upon songID + difficulty. However, you
// cannot have a PB on both a HARD BSC and a BSC counted for
// jubility. This query is awkward. Sorry!
difficulty: {
$switch: {
branches: [
{
case: { $in: ["$chart.difficulty", ["HARD BSC", "BSC"]] },
then: "BSC",
},
{
case: { $in: ["$chart.difficulty", ["HARD ADV", "ADV"]] },
then: "ADV",
},
{
case: { $in: ["$chart.difficulty", ["HARD EXT", "EXT"]] },
then: "EXT",
},
],
},
},
},
doc: { $first: "$$ROOT" },
},
},
// for some godforsaken reason you have to sort twice. after a grouping
// the sort order becomes nondeterministic
{
$sort: {
[`doc.calculatedData.jubility`]: -1,
},
},
{
$limit: limit,
},
]);
return r.map((e) => e.doc);
}
const CURRENT_JUBEAT_HOT_VERSION: Versions["jubeat:Single"] = "festo";
async function GetPBsForJubility(userID: integer) {
const hotSongs = await db.songs.jubeat.find(
{ "data.displayVersion": CURRENT_JUBEAT_HOT_VERSION },
{ projection: { id: 1 } }
);
const hotSongIDs = hotSongs.map((e) => e.id);
const coldSongs = await db.songs.jubeat.find(
{ "data.displayVersion": { $ne: CURRENT_JUBEAT_HOT_VERSION } },
{ projection: { id: 1 } }
);
const coldSongIDs = coldSongs.map((e) => e.id);
const [bestHotScores, bestScores] = await Promise.all([
GetBestJubilityOnSongs(hotSongIDs, userID, "jubeat", "Single", 30),
GetBestJubilityOnSongs(coldSongIDs, userID, "jubeat", "Single", 30),
]);
return { bestHotScores, bestScores };
}
async function CalculateJubility(game: Game, playtype: Playtype, userID: integer): Promise<number> {
const { bestHotScores, bestScores } = await GetPBsForJubility(userID);
let jubility = 0;
jubility = jubility + bestHotScores.reduce((a, e) => a + (e.calculatedData.jubility ?? 0), 0);
jubility = jubility + bestScores.reduce((a, e) => a + (e.calculatedData.jubility ?? 0), 0);
return jubility;
}
async function CalculateGitadoraSkill(game: Game, playtype: Playtype, userID: integer) {
const hotSongIDs = (
await db.songs.gitadora.find({ "data.isHot": true }, { projection: { id: 1 } })
).map((e) => e.id);
// get it
const coldSongIDs = (
await db.songs.gitadora.find({ "data.isHot": false }, { projection: { id: 1 } })
).map((e) => e.id);
const [bestHotScores, bestScores] = await Promise.all([
GetBestRatingOnSongs(hotSongIDs, userID, game, playtype, "skill", 25),
GetBestRatingOnSongs(coldSongIDs, userID, game, playtype, "skill", 25),
]);
if (bestHotScores.length + bestScores.length === 0) {
return null;
}
let skill = 0;
skill = skill + bestHotScores.reduce((a, e) => a + e.calculatedData.skill!, 0);
skill = skill + bestScores.reduce((a, e) => a + e.calculatedData.skill!, 0);
return skill;
}
export const PROFILE_CALCULATORS: GPTProfileCalculators = {
"iidx:SP": {
BPI: AvgBestN("BPI", 20, true),
ktLampRating: AvgBestN("ktLampRating", 20),
},
"iidx:DP": {
BPI: AvgBestN("BPI", 20, true),
ktLampRating: AvgBestN("ktLampRating", 20),
},
"bms:14K": { sieglinde: AvgBestN("sieglinde", 20) },
"bms:7K": { sieglinde: AvgBestN("sieglinde", 20) },
"pms:Controller": { sieglinde: AvgBestN("sieglinde", 20) },
"pms:Keyboard": { sieglinde: AvgBestN("sieglinde", 20) },
"gitadora:Dora": { skill: CalculateGitadoraSkill, naiveSkill: SumBestN("skill", 50) },
"gitadora:Gita": { skill: CalculateGitadoraSkill, naiveSkill: SumBestN("skill", 50) },
"chunithm:Single": { naiveRating: AvgBestN("rating", 20) },
"itg:Stamina": {
// the sum of your 1 best blockrating/fastest32 is basically just
// picking your best blockrating/fastest32. neat.
highestBlock: SumBestN("blockRating", 1, true),
fastest32: SumBestN("fastest32", 1, true),
},
"jubeat:Single": {
jubility: CalculateJubility,
naiveJubility: SumBestN("jubility", 60),
},
"maimaidx:Single": {
rate: CalculateMaimaiDXRate,
naiveRate: SumBestN("rate", 50),
},
"museca:Single": {
curatorSkill: SumBestN("curatorSkill", 20),
},
"popn:9B": {
naiveClassPoints: SumBestN("classPoints", 20),
},
"sdvx:Single": {
VF6: SumBestN("VF6", 50),
},
"usc:Controller": {
VF6: SumBestN("VF6", 50),
},
"usc:Keyboard": {
VF6: SumBestN("VF6", 50),
},
"wacca:Single": {
rate: CalculateWACCARate,
naiveRate: SumBestN("rate", 50),
},
};
/**
* Calculate profile ratings for this UGPT.
*/
export async function CalculateProfileRatings(game: Game, playtype: Playtype, userID: integer) {
const calculatedData: Record<string, number | null> = {};
const gptString = GetGPTString(game, playtype);
// Do this in parallel for performance.
await Promise.all(
Object.entries(PROFILE_CALCULATORS[gptString]).map(async ([key, fn]) => {
calculatedData[key] = await fn(game, playtype, userID);
})
);
return calculatedData as UserGameStats["ratings"];
}
@@ -1,14 +1,19 @@
import type { DryScore } from "../common/types";
import type { KtLogger } from "lib/logger/logger";
import type {
GPTString,
ChartDocument,
ScoreRatingAlgorithms,
SessionRatingAlgorithms,
ScoreDocument,
ClassConfigs,
GPTString,
GPTStringToGame,
GPTStringToPlaytype,
ProfileRatingAlgorithms,
ScoreDocument,
ScoreRatingAlgorithms,
SessionRatingAlgorithms,
UserGameStats,
integer,
} from "tachi-common";
import type { DerivedClassConfig, ProvidedClassConfig } from "tachi-common/types/game-config-utils";
export type ScoreCalculator<GPT extends GPTString> = (
dryScore: DryScore<GPT>,
@@ -37,5 +42,43 @@ export type GPTSessionCalculators = {
};
export type GPTProfileCalculators = {
[G in GPTString]: Record<SessionRatingAlgorithms[G], ScoreCalculator<G>>;
[G in GPTString]: Record<ProfileRatingAlgorithms[G], ProfileCalculator<G>>;
};
export type ClassDeriver<GPT extends GPTString, V extends string> = (
profileRatings: UserGameStats<GPT>["ratings"]
) => V | null;
// absolutely stupid magic.
// makes a record of all the classes that are derivable,
// and returns a ClassDeriver function for each: i.e.
// wacca has StageUP which is not derivable, and colour (which is)
// so this type will result in
// {
// colour: ClassDeriver<"YELLOW" | "asdf" ...>
// }
type RecordClassDeriver<GPT extends GPTString> = {
[C in keyof ClassConfigs[GPT] as ClassConfigs[GPT][C] extends DerivedClassConfig
? C
: never]: ClassConfigs[GPT][C] extends DerivedClassConfig<infer V>
? ClassDeriver<GPT, V>
: never;
};
type RecordClassProvider<GPT extends GPTString> = {
[C in keyof ClassConfigs[GPT] as ClassConfigs[GPT][C] extends DerivedClassConfig
? C
: never]: ClassConfigs[GPT][C] extends ProvidedClassConfig<infer V> ? V : never;
};
export type GPTClassDerivers = {
[GPT in GPTString]: RecordClassDeriver<GPT>;
};
export type ClassProvider<GPT extends GPTString = GPTString> = (
gptString: GPT,
userID: integer,
ratings: Record<string, number | null>,
logger: KtLogger
) => Partial<RecordClassProvider<GPT>> | Promise<Partial<RecordClassProvider<GPT>>> | undefined;
@@ -1,107 +0,0 @@
import {
CalculateGitadoraColour,
GitadoraSkillToColour,
CalculateSDVXClass,
SDVXVF6ToClass,
} from "./builtin-class-handlers";
import CreateLogCtx from "lib/logger/logger";
import { GITADORA_COLOURS, SDVX_VF_CLASSES } from "tachi-common";
import t from "tap";
const logger = CreateLogCtx(__filename);
t.test("#CalculateGitadoraColour", (t) => {
t.strictSame(
CalculateGitadoraColour("gitadora", "Gita", 1, { skill: 1500 }),
{
colour: GITADORA_COLOURS.ORANGE_GRADIENT,
},
"Should wrap the skill colour."
);
t.end();
});
t.test("#GitadoraSkillToColour", (t) => {
t.equal(GitadoraSkillToColour(0), GITADORA_COLOURS.WHITE);
t.equal(GitadoraSkillToColour(999), GITADORA_COLOURS.WHITE);
t.equal(GitadoraSkillToColour(1000), GITADORA_COLOURS.ORANGE);
t.equal(GitadoraSkillToColour(1500), GITADORA_COLOURS.ORANGE_GRADIENT);
t.equal(GitadoraSkillToColour(2000), GITADORA_COLOURS.YELLOW);
t.equal(GitadoraSkillToColour(2500), GITADORA_COLOURS.YELLOW_GRADIENT);
t.equal(GitadoraSkillToColour(3000), GITADORA_COLOURS.GREEN);
t.equal(GitadoraSkillToColour(3500), GITADORA_COLOURS.GREEN_GRADIENT);
t.equal(GitadoraSkillToColour(4000), GITADORA_COLOURS.BLUE);
t.equal(GitadoraSkillToColour(4500), GITADORA_COLOURS.BLUE_GRADIENT);
t.equal(GitadoraSkillToColour(5000), GITADORA_COLOURS.PURPLE);
t.equal(GitadoraSkillToColour(5500), GITADORA_COLOURS.PURPLE_GRADIENT);
t.equal(GitadoraSkillToColour(6000), GITADORA_COLOURS.RED);
t.equal(GitadoraSkillToColour(6500), GITADORA_COLOURS.RED_GRADIENT);
t.equal(GitadoraSkillToColour(7000), GITADORA_COLOURS.BRONZE);
t.equal(GitadoraSkillToColour(7500), GITADORA_COLOURS.SILVER);
t.equal(GitadoraSkillToColour(8000), GITADORA_COLOURS.GOLD);
t.equal(GitadoraSkillToColour(8500), GITADORA_COLOURS.RAINBOW);
t.end();
});
t.test("#CalculateSDVXClass", (t) => {
t.strictSame(CalculateSDVXClass("sdvx", "Single", 1, { VF6: 10 }, logger), {
vfClass: SDVX_VF_CLASSES.COBALT_I,
});
t.end();
});
t.test("#SDVXVF6ToClass", (t) => {
function f(vf: number) {
return SDVXVF6ToClass(vf, logger);
}
t.equal(f(0), SDVX_VF_CLASSES.SIENNA_I);
t.equal(f(2.5), SDVX_VF_CLASSES.SIENNA_II);
t.equal(f(5), SDVX_VF_CLASSES.SIENNA_III);
t.equal(f(7.5), SDVX_VF_CLASSES.SIENNA_IV);
t.equal(f(10), SDVX_VF_CLASSES.COBALT_I);
t.equal(f(10.5), SDVX_VF_CLASSES.COBALT_II);
t.equal(f(11), SDVX_VF_CLASSES.COBALT_III);
t.equal(f(11.5), SDVX_VF_CLASSES.COBALT_IV);
t.equal(f(12), SDVX_VF_CLASSES.DANDELION_I);
t.equal(f(12.5), SDVX_VF_CLASSES.DANDELION_II);
t.equal(f(13), SDVX_VF_CLASSES.DANDELION_III);
t.equal(f(13.5), SDVX_VF_CLASSES.DANDELION_IV);
t.equal(f(14), SDVX_VF_CLASSES.CYAN_I);
t.equal(f(14.25), SDVX_VF_CLASSES.CYAN_II);
t.equal(f(14.5), SDVX_VF_CLASSES.CYAN_III);
t.equal(f(14.75), SDVX_VF_CLASSES.CYAN_IV);
t.equal(f(15), SDVX_VF_CLASSES.SCARLET_I);
t.equal(f(15.25), SDVX_VF_CLASSES.SCARLET_II);
t.equal(f(15.5), SDVX_VF_CLASSES.SCARLET_III);
t.equal(f(15.75), SDVX_VF_CLASSES.SCARLET_IV);
t.equal(f(16), SDVX_VF_CLASSES.CORAL_I);
t.equal(f(16.25), SDVX_VF_CLASSES.CORAL_II);
t.equal(f(16.5), SDVX_VF_CLASSES.CORAL_III);
t.equal(f(16.75), SDVX_VF_CLASSES.CORAL_IV);
t.equal(f(17), SDVX_VF_CLASSES.ARGENTO_I);
t.equal(f(17.25), SDVX_VF_CLASSES.ARGENTO_II);
t.equal(f(17.5), SDVX_VF_CLASSES.ARGENTO_III);
t.equal(f(17.75), SDVX_VF_CLASSES.ARGENTO_IV);
t.equal(f(18), SDVX_VF_CLASSES.ELDORA_I);
t.equal(f(18.25), SDVX_VF_CLASSES.ELDORA_II);
t.equal(f(18.5), SDVX_VF_CLASSES.ELDORA_III);
t.equal(f(18.75), SDVX_VF_CLASSES.ELDORA_IV);
t.equal(f(19), SDVX_VF_CLASSES.CRIMSON_I);
t.equal(f(19.25), SDVX_VF_CLASSES.CRIMSON_II);
t.equal(f(19.5), SDVX_VF_CLASSES.CRIMSON_III);
t.equal(f(19.75), SDVX_VF_CLASSES.CRIMSON_IV);
t.equal(f(20), SDVX_VF_CLASSES.IMPERIAL_I);
t.equal(f(21), SDVX_VF_CLASSES.IMPERIAL_II);
t.equal(f(22), SDVX_VF_CLASSES.IMPERIAL_III);
t.equal(f(23), SDVX_VF_CLASSES.IMPERIAL_IV);
t.equal(f(24), SDVX_VF_CLASSES.IMPERIAL_IV);
t.equal(f(1000), SDVX_VF_CLASSES.IMPERIAL_IV);
t.end();
});
@@ -1,298 +0,0 @@
import {
CHUNITHM_COLOURS,
GITADORA_COLOURS,
JUBEAT_COLOURS,
MAIMAIDX_COLOURS,
POPN_CLASSES,
SDVX_VF_CLASSES,
WACCA_COLOURS,
} from "tachi-common";
import { IsNullish } from "utils/misc";
import type { KtLogger } from "lib/logger/logger";
import type { Game, integer, Playtype, ScoreCalculatedDataLookup } from "tachi-common";
import type { ClassRecords } from "tachi-common/game-classes";
export function CalculateSDVXClass(
game: Game,
playtype: Playtype,
userID: integer,
ratings: Partial<Record<ScoreCalculatedDataLookup["sdvx:Single"], number>>,
logger: KtLogger
): Partial<ClassRecords<"sdvx:Single">> {
if (ratings.VF6 === undefined) {
return {};
}
return { vfClass: SDVXVF6ToClass(ratings.VF6, logger) };
}
/**
* Converts a user's profile VF6 to a class. There are almost 40 classes, and writing
* if statements for all of them is a painful endeavour. This function returns an index
* derived from the gaps between classes, to avoid writing out all those statements.
* @see https://bemaniwiki.com/index.php?SOUND%20VOLTEX%20VIVID%20WAVE/VOLFORCE
*/
export function SDVXVF6ToClass(vf: number, logger: KtLogger) {
// This is impossible, but a failsafe regardless
if (vf >= 24) {
logger.warn(`User has excessive VF6 of ${vf}. Defaulting to Imperial IV.`);
return SDVX_VF_CLASSES.IMPERIAL_IV;
} else if (vf >= 20) {
// imperial i -> iv has gaps of 1
return SDVX_VF_CLASSES.IMPERIAL_I + Math.floor(vf - 20);
} else if (vf >= 14) {
// cyan i -> crimson iv has gaps of 0.25
return SDVX_VF_CLASSES.CYAN_I + Math.floor(4 * (vf - 14));
} else if (vf >= 10) {
// cobalt i -> dandelion iv have gaps of 0.5
return SDVX_VF_CLASSES.COBALT_I + Math.floor(2 * (vf - 10));
}
return Math.floor(vf / 2.5);
}
export function CalculateGitadoraColour(
game: Game,
playtype: Playtype,
userID: integer,
ratings: Record<string, number | null>
): Partial<ClassRecords<"gitadora:Dora" | "gitadora:Gita">> {
if (IsNullish(ratings.skill)) {
return {};
}
const colour = GitadoraSkillToColour(ratings.skill);
return {
colour,
};
}
export function GitadoraSkillToColour(sk: number) {
if (sk >= 8500) {
return GITADORA_COLOURS.RAINBOW;
} else if (sk >= 8000) {
return GITADORA_COLOURS.GOLD;
} else if (sk >= 7500) {
return GITADORA_COLOURS.SILVER;
} else if (sk >= 7000) {
return GITADORA_COLOURS.BRONZE;
} else if (sk >= 6500) {
return GITADORA_COLOURS.RED_GRADIENT;
} else if (sk >= 6000) {
return GITADORA_COLOURS.RED;
} else if (sk >= 5500) {
return GITADORA_COLOURS.PURPLE_GRADIENT;
} else if (sk >= 5000) {
return GITADORA_COLOURS.PURPLE;
} else if (sk >= 4500) {
return GITADORA_COLOURS.BLUE_GRADIENT;
} else if (sk >= 4000) {
return GITADORA_COLOURS.BLUE;
} else if (sk >= 3500) {
return GITADORA_COLOURS.GREEN_GRADIENT;
} else if (sk >= 3000) {
return GITADORA_COLOURS.GREEN;
} else if (sk >= 2500) {
return GITADORA_COLOURS.YELLOW_GRADIENT;
} else if (sk >= 2000) {
return GITADORA_COLOURS.YELLOW;
} else if (sk >= 1500) {
return GITADORA_COLOURS.ORANGE_GRADIENT;
} else if (sk >= 1000) {
return GITADORA_COLOURS.ORANGE;
}
return GITADORA_COLOURS.WHITE;
}
export function CalculateWACCAColour(
game: Game,
playtype: Playtype,
userID: integer,
ratings: Record<string, number | null>
) {
if (IsNullish(ratings.rate)) {
return {};
}
const colour = WACCARateToColour(ratings.rate);
return { colour };
}
function WACCARateToColour(rate: number) {
if (rate >= 2500) {
return WACCA_COLOURS.RAINBOW;
} else if (rate >= 2200) {
return WACCA_COLOURS.GOLD;
} else if (rate >= 1900) {
return WACCA_COLOURS.SILVER;
} else if (rate >= 1600) {
return WACCA_COLOURS.BLUE;
} else if (rate >= 1300) {
return WACCA_COLOURS.PURPLE;
} else if (rate >= 1000) {
return WACCA_COLOURS.RED;
} else if (rate >= 600) {
return WACCA_COLOURS.YELLOW;
} else if (rate >= 300) {
return WACCA_COLOURS.NAVY;
}
return WACCA_COLOURS.ASH;
}
export function CalculatePopnClass(
game: Game,
playtype: Playtype,
userID: integer,
ratings: Record<string, number | null>
) {
if (IsNullish(ratings.naiveClassPoints)) {
return {};
}
const cls = PopnClassPointsToClass(ratings.naiveClassPoints);
return { class: cls };
}
function PopnClassPointsToClass(points: number) {
if (points < 21) {
return POPN_CLASSES.KITTY;
} else if (points < 34) {
return POPN_CLASSES.GRADE_SCHOOL;
} else if (points < 46) {
return POPN_CLASSES.DELINQUENT;
} else if (points < 59) {
return POPN_CLASSES.DETECTIVE;
} else if (points < 68) {
return POPN_CLASSES.IDOL;
} else if (points < 79) {
return POPN_CLASSES.GENERAL;
} else if (points < 91) {
return POPN_CLASSES.HERMIT;
}
return POPN_CLASSES.GOD;
}
export function CalculateChunithmColour(
game: Game,
playtype: Playtype,
userID: integer,
ratings: Record<string, number | null>
) {
if (IsNullish(ratings.naiveRating)) {
return {};
}
const colour = ChuniRatingToColour(ratings.naiveRating);
return { colour };
}
function ChuniRatingToColour(rating: number) {
if (rating >= 15) {
return CHUNITHM_COLOURS.RAINBOW;
} else if (rating >= 14.5) {
return CHUNITHM_COLOURS.PLATINUM;
} else if (rating >= 14) {
return CHUNITHM_COLOURS.GOLD;
} else if (rating >= 13) {
return CHUNITHM_COLOURS.SILVER;
} else if (rating >= 12) {
return CHUNITHM_COLOURS.BRONZE;
} else if (rating >= 10) {
return CHUNITHM_COLOURS.PURPLE;
} else if (rating >= 7) {
return CHUNITHM_COLOURS.RED;
} else if (rating >= 4) {
return CHUNITHM_COLOURS.ORANGE;
} else if (rating >= 2) {
return CHUNITHM_COLOURS.GREEN;
}
return CHUNITHM_COLOURS.BLUE;
}
export function CalculateJubeatColour(
game: Game,
playtype: Playtype,
userID: integer,
ratings: Record<string, number | null>
) {
if (IsNullish(ratings.jubility)) {
return {};
}
const colour = JubilityToColour(ratings.jubility);
return { colour };
}
function JubilityToColour(jubility: number) {
if (jubility >= 9500) {
return JUBEAT_COLOURS.GOLD;
} else if (jubility >= 8500) {
return JUBEAT_COLOURS.ORANGE;
} else if (jubility >= 7000) {
return JUBEAT_COLOURS.PINK;
} else if (jubility >= 5500) {
return JUBEAT_COLOURS.PURPLE;
} else if (jubility >= 4000) {
return JUBEAT_COLOURS.VIOLET;
} else if (jubility >= 2500) {
return JUBEAT_COLOURS.BLUE;
} else if (jubility >= 1500) {
return JUBEAT_COLOURS.LIGHT_BLUE;
} else if (jubility >= 750) {
return JUBEAT_COLOURS.GREEN;
} else if (jubility >= 250) {
return JUBEAT_COLOURS.YELLOW_GREEN;
}
return JUBEAT_COLOURS.BLACK;
}
export function CalculateMaimaiDXColour(
game: Game,
playtype: Playtype,
userID: integer,
ratings: Record<string, number | null>
) {
if (IsNullish(ratings.rate)) {
return {};
}
const colour = MaimaiDXRateToColour(ratings.rate);
return { colour };
}
function MaimaiDXRateToColour(rate: number) {
if (rate >= 15000) {
return MAIMAIDX_COLOURS.RAINBOW;
} else if (rate >= 14500) {
return MAIMAIDX_COLOURS.PLATINUM;
} else if (rate >= 14000) {
return MAIMAIDX_COLOURS.GOLD;
} else if (rate >= 13000) {
return MAIMAIDX_COLOURS.SILVER;
} else if (rate >= 12000) {
return MAIMAIDX_COLOURS.BRONZE;
} else if (rate >= 10000) {
return MAIMAIDX_COLOURS.PURPLE;
} else if (rate >= 7000) {
return MAIMAIDX_COLOURS.RED;
} else if (rate >= 4000) {
return MAIMAIDX_COLOURS.YELLOW;
} else if (rate >= 2000) {
return MAIMAIDX_COLOURS.GREEN;
} else if (rate >= 1000) {
return MAIMAIDX_COLOURS.BLUE;
}
return MAIMAIDX_COLOURS.WHITE;
}
@@ -1,4 +1,4 @@
import { ProcessClassDeltas, UpdateUGSClasses } from "./classes";
import { ProcessClassDeltas, CalculateUGPTClasses } from "./classes";
import CreateLogCtx from "lib/logger/logger";
import { GITADORA_COLOURS } from "tachi-common";
import t from "tap";
@@ -9,7 +9,7 @@ const logger = CreateLogCtx(__filename);
t.test("#UpdateUGSClasses", (t) => {
t.test("Should produce an empty object by default", async (t) => {
const res = await UpdateUGSClasses("iidx", "SP", 1, {}, null, logger);
const res = await CalculateUGPTClasses("iidx", "SP", 1, {}, null, logger);
t.strictSame(res, {});
@@ -17,7 +17,7 @@ t.test("#UpdateUGSClasses", (t) => {
});
t.test("Should call and merge the ClassHandler", async (t) => {
const res = await UpdateUGSClasses("iidx", "SP", 1, {}, () => ({ dan: 2 }), logger);
const res = await CalculateUGPTClasses("iidx", "SP", 1, {}, () => ({ dan: 2 }), logger);
t.strictSame(res, { dan: 2 });
@@ -25,7 +25,7 @@ t.test("#UpdateUGSClasses", (t) => {
});
t.test("Should call static handlers if there is one", async (t) => {
const res = await UpdateUGSClasses(
const res = await CalculateUGPTClasses(
"gitadora",
"Dora",
1,
@@ -1,78 +1,23 @@
import {
CalculateChunithmColour,
CalculateGitadoraColour,
CalculateJubeatColour,
CalculatePopnClass,
CalculateSDVXClass,
CalculateWACCAColour,
CalculateMaimaiDXColour,
} from "./builtin-class-handlers";
import { CalculateDerivedClasses } from "../calculated-data/profile-classes";
import deepmerge from "deepmerge";
import db from "external/mongo/db";
import { EmitWebhookEvent } from "lib/webhooks/webhooks";
import { GetGamePTConfig } from "tachi-common";
import { GetGPTString, GetGamePTConfig } from "tachi-common";
import { ReturnClassIfGreater } from "utils/class";
import type { ClassHandler, ScoreClasses } from "./types";
import type { ClassProvider } from "../calculated-data/types";
import type { KtLogger } from "lib/logger/logger";
import type {
ClassDelta,
Game,
GPTString,
integer,
Playtype,
Playtypes,
UserGameStats,
Classes,
GPTString,
Game,
Playtype,
UserGameStats,
integer,
ExtractedClasses,
AnyClasses,
} from "tachi-common";
type ClassHandlerMap = {
[G in Game]:
| {
[P in Playtypes[G]]: ClassHandler;
}
| null;
};
/**
* Describes the static class handlers for a game, I.E. ones that are
* always meant to be called when new scores are found.
*
* These can be calculated without any external (i.e. user/api-provided) data.
*
* A good example of this would be, say, jubeat's colours - the boundaries depend entirely
* on your profile skill level, which is known at the time this function is called.
*/
const STATIC_CLASS_HANDLERS: ClassHandlerMap = {
iidx: null,
bms: null,
chunithm: {
Single: CalculateChunithmColour,
},
gitadora: {
Gita: CalculateGitadoraColour,
Dora: CalculateGitadoraColour,
},
maimaidx: {
Single: CalculateMaimaiDXColour,
},
museca: null,
popn: {
"9B": CalculatePopnClass,
},
sdvx: {
Single: CalculateSDVXClass,
},
usc: null,
wacca: {
Single: CalculateWACCAColour,
},
jubeat: {
Single: CalculateJubeatColour,
},
pms: null,
itg: null,
};
/**
* Calculates a User's Game Stats Classes. This function is rather complex, because the reality is rather complex.
*
@@ -90,36 +35,28 @@ const STATIC_CLASS_HANDLERS: ClassHandlerMap = {
* @param ratings - A users ratings. This is calculated in rating.ts, and passed via update-ugs.ts.
* We request this because we need it for things like gitadora's skill divisions - We don't need to calculate our skill
* statistic twice if we just request it be passed to us!
* @param ClassHandler - The Custom Resolve Function that certain import types may pass to us as a means
* for retrieving information about a class. This returns the same thing as this function, and it is merged with the
* @param ClassProvider - The Custom Resolve Function that certain import types may pass to us as a means
* for providing information about a class. This returns the same thing as this function, and it is merged with the
* defaults.
*/
export async function UpdateUGSClasses(
export async function CalculateUGPTClasses(
game: Game,
playtype: Playtype,
userID: integer,
ratings: Record<string, number | null>,
ClassHandler: ClassHandler | null,
ClassProvider: ClassProvider | null,
logger: KtLogger
): Promise<ScoreClasses> {
let classes: ScoreClasses = {};
): Promise<ExtractedClasses[GPTString]> {
const gptString = GetGPTString(game, playtype);
// @ts-expect-error This one sucks - I need to look into a better way of representing these types
if (STATIC_CLASS_HANDLERS[game]?.[playtype] !== undefined) {
// @ts-expect-error see above
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
classes = await STATIC_CLASS_HANDLERS[game][playtype](
game,
playtype,
userID,
ratings,
logger
);
}
// Derive all classes first.
let classes = CalculateDerivedClasses(gptString, ratings);
if (ClassHandler) {
// If this import method is providing us classes, merge those with the
// other classes we have.
if (ClassProvider) {
logger.debug(`Calling custom class handler.`);
const customClasses = (await ClassHandler(game, playtype, userID, ratings, logger)) ?? {};
const customClasses = (await ClassProvider(gptString, userID, ratings, logger)) ?? {};
classes = deepmerge(customClasses, classes);
}
@@ -131,17 +68,24 @@ export async function UpdateUGSClasses(
* Calculates the class "deltas" for this users classes.
* This is for calculating scenarios where a users class has improved (i.e. they have gone from 9th dan to 10th dan).
*
* If a class is provided, we don't want to potentially downgrade users. I.e.
* if a user clears 7th dan while they're 10th dan, we don't want to downgrade them.
*
* For derived classes however, downgrading is fine.
*
* Knowing this information allows us to attach it onto the import, and also emit things on webhooks.
* This function emits webhook events and inserts classachieved documents into the DB!**
* This function emits webhook events and inserts classachieved documents into the DB!
*/
export async function ProcessClassDeltas(
game: Game,
playtype: Playtype,
classes: ScoreClasses,
classes: AnyClasses,
userGameStats: UserGameStats | null,
userID: integer,
logger: KtLogger
): Promise<Array<ClassDelta>> {
const gptString = GetGPTString(game, playtype);
const deltas: Array<ClassDelta> = [];
const achievementOps = [];
@@ -152,7 +96,7 @@ export async function ProcessClassDeltas(
const classSet = s as Classes[GPTString];
const classVal = classes[classSet];
if (classVal === undefined) {
if (classVal === undefined || classVal === null) {
logger.debug(`Skipped deltaing-class ${classSet}.`);
continue;
}
@@ -160,11 +104,11 @@ export async function ProcessClassDeltas(
const classConfig = gptConfig.classes[classSet]!;
try {
const isGreater = ReturnClassIfGreater(classSet, classVal, userGameStats);
const isGreater = ReturnClassIfGreater(gptString, classSet, classVal, userGameStats);
// if this was worse, and this class isn't DERIVED (i.e. it's a dan)
// if this was worse, and this class is PROVIDED (i.e. it's a dan)
// then don't do anything
if (isGreater === false && classConfig.type !== "DERIVED") {
if (isGreater === false && classConfig.type === "PROVIDED") {
continue;
} else {
// otherwise, provide this as an update.
@@ -1,76 +0,0 @@
import { CalculateRatings } from "./rating";
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import t from "tap";
import { mkFakePBIIDXSP, mkFakePBJubeat } from "test-utils/misc";
import ResetDBState from "test-utils/resets";
const logger = CreateLogCtx(__filename);
t.test("#CalculateRatings", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return BPI for IIDX", async (t) => {
const res = await CalculateRatings("iidx", "SP", 1, logger);
t.strictSame(res, { BPI: null, ktLampRating: null }, "Should return BPI as a custom key.");
const resDP = await CalculateRatings("iidx", "DP", 1, logger);
t.strictSame(
resDP,
{ BPI: null, ktLampRating: null },
"Should return BPI as a custom key."
);
t.end();
});
t.test("Should return VF6 for SDVX", async (t) => {
const res = await CalculateRatings("sdvx", "Single", 1, logger);
t.strictSame(res, { VF6: null }, "Should return VF6 keys.");
t.end();
});
t.test("Should return VF6 for USC:Keyboard", async (t) => {
const res = await CalculateRatings("usc", "Keyboard", 1, logger);
t.strictSame(res, { VF6: null }, "Should return VF6 keys.");
t.end();
});
t.test("Should return VF6 for USC:Controller", async (t) => {
const res = await CalculateRatings("usc", "Controller", 1, logger);
t.strictSame(res, { VF6: null }, "Should return VF6 keys.");
t.end();
});
t.test("Should return skill for Gitadora", async (t) => {
const res = await CalculateRatings("gitadora", "Dora", 1, logger);
t.strictSame(res, { skill: null, naiveSkill: null }, "Should return skill keys.");
const res2 = await CalculateRatings("gitadora", "Gita", 1, logger);
t.strictSame(res2, { skill: null, naiveSkill: null }, "Should return skill keys.");
t.end();
});
t.test("Should calculate jubility for Jubeat", async (t) => {
await db["personal-bests"].insert(mkFakePBJubeat());
const res = await CalculateRatings("jubeat", "Single", 1, logger);
t.strictSame(res, { jubility: 5, naiveJubility: 5 }, "Should return jubility keys.");
t.end();
});
t.end();
});
@@ -1,477 +0,0 @@
import db from "external/mongo/db";
import type { KtLogger } from "lib/logger/logger";
import type {
Game,
Versions,
GPTString,
integer,
PBScoreDocument,
Playtype,
UserGameStats,
ScoreRatingAlgorithms,
} from "tachi-common";
type CustomCalcNames = ScoreRatingAlgorithms[GPTString];
/**
* Curries a function that returns the sum of N best ratings on `key`.
*
* @param key - What rating value to sum.
* @param n - The amount of rating values to pull.
* @param returnMean - Optionally, if true, return the sum of these values divided by N.
* @param nullIfNotEnoughScores - If true, return null if the total scores this user has is less than N.
*
* @returns - Number if the user has scores with that rating algorithm, null if they have
* no scores with this rating algorithm that are non-null.
*/
function LazyCalcN(
key: CustomCalcNames,
n: integer,
returnMean = false,
nullIfNotEnoughScores = false
) {
return async (game: Game, playtype: Playtype, userID: integer) => {
const sc = await db["personal-bests"].find(
{
game,
playtype,
userID,
isPrimary: true,
[`calculatedData.${key}`]: { $type: "number" },
},
{
limit: n,
sort: { [`calculatedData.${key}`]: -1 },
}
);
if (sc.length === 0) {
return null;
}
if (nullIfNotEnoughScores && sc.length < n) {
return null;
}
let result = sc.reduce((a, e) => a + e.calculatedData[key]!, 0);
if (returnMean) {
result = result / n;
}
return result;
};
}
const LazySumN = (key: CustomCalcNames, n: integer, nullIfNotEnoughScores = false) =>
LazyCalcN(key, n, false, nullIfNotEnoughScores);
const LazyMeanN = (key: CustomCalcNames, n: integer, nullIfNotEnoughScores = false) =>
LazyCalcN(key, n, true, nullIfNotEnoughScores);
type RatingFunction = (
game: Game,
playtype: Playtype,
userID: integer,
logger: KtLogger
) => Promise<UserGameStats["ratings"]>;
function GetGPTRatingFunction(game: Game, playtype: Playtype): RatingFunction {
switch (`${game}:${playtype}` as GPTString) {
case "iidx:SP":
case "iidx:DP":
return async (g, p, u) => ({
BPI: await LazyMeanN("BPI", 20, true)(g, p, u),
ktLampRating: await LazyMeanN("ktLampRating", 20)(g, p, u),
});
case "sdvx:Single":
case "usc:Keyboard":
case "usc:Controller":
return async (g, p, u) => ({
VF6: await LazySumN("VF6", 50)(g, p, u),
});
case "gitadora:Dora":
case "gitadora:Gita":
return async (g, p, u, l) => ({
skill: await CalculateGitadoraSkill(g, p, u, l),
naiveSkill: await LazySumN("skill", 50)(g, p, u),
});
case "bms:7K":
case "bms:14K":
case "pms:Keyboard":
case "pms:Controller":
return async (g, p, u) => ({
sieglinde: await LazyMeanN("sieglinde", 20)(g, p, u),
});
case "chunithm:Single":
return async (g, p, u) => ({
naiveRating: await LazyMeanN("rating", 20)(g, p, u),
});
case "popn:9B":
return async (g, p, u) => ({
naiveClassPoints: await LazyMeanN("classPoints", 20)(g, p, u),
});
case "museca:Single":
return async (g, p, u) => ({
curatorSkill: await LazySumN("curatorSkill", 20)(g, p, u),
});
case "maimaidx:Single":
return async (g, p, u, l) => ({
naiveRate: await LazySumN("rate", 50)(g, p, u),
rate: await CalculateMaimaiDXRate(g, p, u, l),
});
case "wacca:Single":
return async (g, p, u, l) => ({
naiveRate: await LazySumN("rate", 50)(g, p, u),
rate: await CalculateWACCARate(g, p, u, l),
});
case "jubeat:Single":
return async (g, p, u, l) => ({
jubility: await CalculateJubility(g, p, u, l),
naiveJubility: await LazySumN("jubility", 60)(g, p, u),
});
case "itg:Stamina":
// HighestX is equivalent to summing 1.
return async (g, p, u) => ({
highestBlock: await LazySumN("blockRating", 1, true)(g, p, u),
highest32: await LazySumN("fastest32", 1, true)(g, p, u),
});
}
}
export function CalculateRatings(
game: Game,
playtype: Playtype,
userID: integer,
logger: KtLogger
): Promise<Partial<Record<CustomCalcNames, number | null>>> {
const RatingFunction = GetGPTRatingFunction(game, playtype);
return RatingFunction(game, playtype, userID, logger);
}
// Wacca has a funny algorithm for rate involving gitadora-style latest chart bonuses,
async function CalculateWACCARate(
game: Game,
playtype: Playtype,
userID: integer,
_logger: KtLogger
) {
const hotChartIDs = (
await db.charts.wacca.find({ "data.isHot": true }, { projection: { chartID: 1 } })
).map((e) => e.chartID);
const coldChartIDs = (
await db.charts.wacca.find({ "data.isHot": false }, { projection: { chartID: 1 } })
).map((e) => e.chartID);
const best15Hot = await db["personal-bests"].find(
{
game,
playtype,
userID,
chartID: { $in: hotChartIDs },
"calculatedData.rate": { $type: "number" },
},
{
sort: {
"calculatedData.rate": -1,
},
limit: 15,
projection: {
"calculatedData.rate": 1,
},
}
);
const best35Cold = await db["personal-bests"].find(
{
game,
playtype,
userID,
chartID: { $in: coldChartIDs },
"calculatedData.rate": { $type: "number" },
},
{
sort: {
"calculatedData.rate": -1,
},
limit: 35,
projection: {
"calculatedData.rate": 1,
},
}
);
if (best15Hot.length + best35Cold.length === 0) {
return null;
}
return (
best15Hot.reduce((a, e) => a + e.calculatedData.rate!, 0) +
best35Cold.reduce((a, e) => a + e.calculatedData.rate!, 0)
);
}
// basically the same as WACCA's.
async function CalculateMaimaiDXRate(
game: Game,
playtype: Playtype,
userID: integer,
_logger: KtLogger
) {
const newChartIDs = (
await db.charts.maimaidx.find({ "data.isLatest": true }, { projection: { chartID: 1 } })
).map((e) => e.chartID);
const oldChartIDs = (
await db.charts.maimaidx.find({ "data.isLatest": false }, { projection: { chartID: 1 } })
).map((e) => e.chartID);
const best15New = await db["personal-bests"].find(
{
game,
playtype,
userID,
chartID: { $in: newChartIDs },
"calculatedData.rate": { $type: "number" },
},
{
sort: {
"calculatedData.rate": -1,
},
limit: 15,
projection: {
"calculatedData.rate": 1,
},
}
);
const best35Old = await db["personal-bests"].find(
{
game,
playtype,
userID,
chartID: { $in: oldChartIDs },
"calculatedData.rate": { $type: "number" },
},
{
sort: {
"calculatedData.rate": -1,
},
limit: 35,
projection: {
"calculatedData.rate": 1,
},
}
);
if (best15New.length + best35Old.length === 0) {
return null;
}
return (
best15New.reduce((a, e) => a + e.calculatedData.rate!, 0) +
best35Old.reduce((a, e) => a + e.calculatedData.rate!, 0)
);
}
async function CalculateGitadoraSkill(
game: Game,
playtype: Playtype,
userID: integer,
_logger: KtLogger
) {
const hotSongIDs = (
await db.songs.gitadora.find({ "data.isHot": true }, { projection: { id: 1 } })
).map((e) => e.id);
// get it
const coldSongIDs = (
await db.songs.gitadora.find({ "data.isHot": false }, { projection: { id: 1 } })
).map((e) => e.id);
const [bestHotScores, bestScores] = await Promise.all([
GetBestRatingOnSongs(hotSongIDs, userID, game, playtype, "skill", 25),
GetBestRatingOnSongs(coldSongIDs, userID, game, playtype, "skill", 25),
]);
if (bestHotScores.length + bestScores.length === 0) {
return null;
}
let skill = 0;
skill = skill + bestHotScores.reduce((a, e) => a + e.calculatedData.skill!, 0);
skill = skill + bestScores.reduce((a, e) => a + e.calculatedData.skill!, 0);
return skill;
}
export async function GetBestRatingOnSongs(
songIDs: Array<integer>,
userID: integer,
game: Game,
playtype: Playtype,
ratingProp: "skill",
limit: integer
): Promise<Array<PBScoreDocument>> {
const r: Array<{ doc: PBScoreDocument }> = await db["personal-bests"].aggregate([
{
$match: {
game,
playtype,
userID,
songID: { $in: songIDs },
},
},
{
$sort: {
[`calculatedData.${ratingProp}`]: -1,
},
},
{
$group: {
_id: "$songID",
doc: { $first: "$$ROOT" },
},
},
// for some godforsaken reason you have to sort twice. after a grouping
// the sort order becomes nondeterministic
{
$sort: {
[`doc.calculatedData.${ratingProp}`]: -1,
},
},
{
$limit: limit,
},
]);
return r.map((e) => e.doc);
}
async function GetBestJubilityOnSongs(
songIDs: Array<integer>,
userID: integer,
game: Game,
playtype: Playtype,
limit: integer
): Promise<Array<PBScoreDocument>> {
const r: Array<{ doc: PBScoreDocument }> = await db["personal-bests"].aggregate([
{
$match: {
game,
playtype,
userID,
songID: { $in: songIDs },
},
},
{
// we need to do stuff dependent on the chart difficulty,
// so we need the chart difficulty
$lookup: {
from: "charts-jubeat",
localField: "chartID",
foreignField: "chartID",
as: "chart",
},
},
{
// "chart" is an array unless we unwind it.
$unwind: {
path: "$chart",
},
},
{
// sort on jubility (so we get the best score)
$sort: {
[`calculatedData.jubility`]: -1,
},
},
{
$group: {
_id: {
songID: "$songID",
// Jubility is unique upon songID + difficulty. However, you
// cannot have a PB on both a HARD BSC and a BSC counted for
// jubility. This query is awkward. Sorry!
difficulty: {
$switch: {
branches: [
{
case: { $in: ["$chart.difficulty", ["HARD BSC", "BSC"]] },
then: "BSC",
},
{
case: { $in: ["$chart.difficulty", ["HARD ADV", "ADV"]] },
then: "ADV",
},
{
case: { $in: ["$chart.difficulty", ["HARD EXT", "EXT"]] },
then: "EXT",
},
],
},
},
},
doc: { $first: "$$ROOT" },
},
},
// for some godforsaken reason you have to sort twice. after a grouping
// the sort order becomes nondeterministic
{
$sort: {
[`doc.calculatedData.jubility`]: -1,
},
},
{
$limit: limit,
},
]);
return r.map((e) => e.doc);
}
const CURRENT_JUBEAT_HOT_VERSION: Versions["jubeat:Single"] = "festo";
export async function GetPBsForJubility(userID: integer) {
const hotSongs = await db.songs.jubeat.find(
{ "data.displayVersion": CURRENT_JUBEAT_HOT_VERSION },
{ projection: { id: 1 } }
);
const hotSongIDs = hotSongs.map((e) => e.id);
const coldSongs = await db.songs.jubeat.find(
{ "data.displayVersion": { $ne: CURRENT_JUBEAT_HOT_VERSION } },
{ projection: { id: 1 } }
);
const coldSongIDs = coldSongs.map((e) => e.id);
const [bestHotScores, bestScores] = await Promise.all([
GetBestJubilityOnSongs(hotSongIDs, userID, "jubeat", "Single", 30),
GetBestJubilityOnSongs(coldSongIDs, userID, "jubeat", "Single", 30),
]);
return { bestHotScores, bestScores };
}
async function CalculateJubility(
game: Game,
playtype: Playtype,
userID: integer,
_logger: KtLogger
): Promise<number> {
const { bestHotScores, bestScores } = await GetPBsForJubility(userID);
let jubility = 0;
jubility = jubility + bestHotScores.reduce((a, e) => a + (e.calculatedData.jubility ?? 0), 0);
jubility = jubility + bestScores.reduce((a, e) => a + (e.calculatedData.jubility ?? 0), 0);
return jubility;
}
@@ -1,13 +0,0 @@
import type { KtLogger } from "lib/logger/logger";
import type { Game, GPTString, integer, Playtype } from "tachi-common";
import type { ClassRecords } from "tachi-common/game-classes";
export type ScoreClasses = Partial<ClassRecords<GPTString>>;
export type ClassHandler = (
game: Game,
playtype: Playtype,
userID: integer,
ratings: Record<string, number | null>,
logger: KtLogger
) => Promise<ScoreClasses> | ScoreClasses | undefined;
@@ -8,14 +8,14 @@ import { CheckAndSetOngoingImportLock, UnsetOngoingImportLock } from "../import-
import { ProcessPBs } from "../pb/process-pbs";
import { UpdateUsersQuests } from "../quests/quests";
import { CreateSessions } from "../sessions/sessions";
import { UpdateUsersGamePlaytypeStats } from "../profile-calculated-data/update-ugs";
import { UpdateUsersGamePlaytypeStats } from "../ugpt-stats/update-ugpt-stats";
import db from "external/mongo/db";
import { GetGameConfig } from "tachi-common";
import { GetMillisecondsSince } from "utils/misc";
import { GetUserWithID } from "utils/user";
import type { ConverterFunction, ImportInputParser } from "../../import-types/common/types";
import type { ClassProvider } from "../calculated-data/types";
import type { ChartIDPlaytypeMap, ScorePlaytypeMap } from "../common/types";
import type { ClassHandler } from "../profile-calculated-data/types";
import type { KtLogger } from "lib/logger/logger";
import type { ScoreImportJob } from "lib/score-import/worker/types";
import type {
@@ -87,7 +87,7 @@ export default async function ScoreImportMain<D, C>(
// We get an iterable from the provided parser function, alongside some context and a converter function.
// This iterable does not have to be an array - it's anything that's iterable, like a generator.
const parseTimeStart = process.hrtime.bigint();
const { iterable, context, game, classHandler } = await InputParser(logger);
const { iterable, context, game, classProvider: classProvider } = await InputParser(logger);
const parseTime = GetMillisecondsSince(parseTimeStart);
@@ -172,7 +172,7 @@ export default async function ScoreImportMain<D, C>(
user,
importType,
game,
classHandler,
classProvider,
logger,
job
);
@@ -260,7 +260,7 @@ export async function HandlePostImportSteps(
user: UserDocument,
importType: ImportTypes,
game: Game,
classHandler: ClassHandler | null,
classProvider: ClassProvider | null,
logger: KtLogger,
job: ScoreImportJob | undefined
) {
@@ -324,7 +324,7 @@ export async function HandlePostImportSteps(
// --- 6. Game Stats ---
// This function updates the users "stats" for this game - such as their profile rating or their classes.
const ugsTimeStart = process.hrtime.bigint();
const classDeltas = await UpdateUsersGameStats(game, playtypes, user.id, classHandler, logger);
const classDeltas = await UpdateUsersGameStats(game, playtypes, user.id, classProvider, logger);
const ugsTime = GetMillisecondsSince(ugsTimeStart);
@@ -384,20 +384,20 @@ async function UpdateUsersGameStats(
game: Game,
modifiedPlaytypes: Array<Playtype>,
userID: integer,
classHandler: ClassHandler | null,
classProvider: ClassProvider | null,
logger: KtLogger
) {
const promises = [];
// Instead of using the provided playtypes, run the classHandler on all
// playtypes. This should only happen if a classHandler is provided, and is
// Instead of using the provided playtypes, run the classProvider on all
// playtypes. This should only happen if a classProvider is provided, and is
// a hack fix for things like #480.
const allPlaytypes = GetGameConfig(game).playtypes;
const playtypes = classHandler ? allPlaytypes : modifiedPlaytypes;
const playtypes = classProvider ? allPlaytypes : modifiedPlaytypes;
for (const pt of playtypes) {
promises.push(UpdateUsersGamePlaytypeStats(game, pt, userID, classHandler, logger));
promises.push(UpdateUsersGamePlaytypeStats(game, pt, userID, classProvider, logger));
}
const r = await Promise.all(promises);
@@ -1,4 +1,4 @@
import { UpdateUsersGamePlaytypeStats } from "./update-ugs";
import { UpdateUsersGamePlaytypeStats } from "./update-ugpt-stats";
import deepmerge from "deepmerge";
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
@@ -1,12 +1,8 @@
// "rating" refers to a user's profile statistics, as in their "rating" on a game.
// Some games have dedicated methods to calculate statistics like these, other games do not.
// That's about all there is to it!
import { ProcessClassDeltas, UpdateUGSClasses } from "./classes";
import { CalculateRatings } from "./rating";
import { CalculateProfileRatings } from "../calculated-data/profile";
import { CalculateUGPTClasses, ProcessClassDeltas } from "../profile-calculated-data/classes";
import db from "external/mongo/db";
import { CreateGameSettings } from "lib/game-settings/create-game-settings";
import type { ClassHandler } from "./types";
import type { ClassProvider } from "../calculated-data/types";
import type { KtLogger } from "lib/logger/logger";
import type { ClassDelta, Game, integer, Playtype, UserGameStats } from "tachi-common";
@@ -14,12 +10,12 @@ export async function UpdateUsersGamePlaytypeStats(
game: Game,
playtype: Playtype,
userID: integer,
classHandler: ClassHandler | null,
classProvider: ClassProvider | null,
logger: KtLogger
): Promise<Array<ClassDelta>> {
logger.debug(`Calculating Ratings...`);
const ratings = await CalculateRatings(game, playtype, userID, logger);
const ratings = await CalculateProfileRatings(game, playtype, userID);
// Attempt to find a users game stats if one already exists. If one doesn't exist,
// this is this players first import for this game!
@@ -31,7 +27,14 @@ export async function UpdateUsersGamePlaytypeStats(
logger.debug(`Calculating UGSClasses...`);
const classes = await UpdateUGSClasses(game, playtype, userID, ratings, classHandler, logger);
const classes = await CalculateUGPTClasses(
game,
playtype,
userID,
ratings,
classProvider,
logger
);
logger.debug(`Finished Calculating UGSClasses`);
@@ -44,7 +47,7 @@ export async function UpdateUsersGamePlaytypeStats(
if (userGameStats) {
logger.debug(`Updated player gamestats for ${game} (${playtype})`);
const updateClasses: Record<string, number> = {};
const updateClasses: Record<string, string> = {};
for (const delta of deltas) {
updateClasses[`classes.${delta.set}`] = delta.new;
@@ -134,7 +134,7 @@ export function CreateCGParser<T>(cgGame: CGSupportedGames, service: CGServices)
},
game: CGGameToTachiGame(cgGame),
iterable: scores as Array<T>,
classHandler: null,
classProvider: null,
};
};
}
@@ -1,4 +1,4 @@
import { CreateKaiIIDXClassHandler } from "./class-handler";
import { CreateKaiIIDXClassProvider } from "./class-handler";
import { KaiTypeToBaseURL } from "../utils";
import CreateLogCtx from "lib/logger/logger";
import { IIDX_DANS } from "tachi-common";
@@ -8,10 +8,10 @@ import ResetDBState from "test-utils/resets";
const logger = CreateLogCtx(__filename);
t.test("#CreateKaiIIDXClassHandler", async (t) => {
t.test("#CreateKaiIIDXClassProvider", async (t) => {
t.beforeEach(ResetDBState);
const fn = await CreateKaiIIDXClassHandler(
const fn = await CreateKaiIIDXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -46,7 +46,7 @@ t.test("#CreateKaiIIDXClassHandler", async (t) => {
});
t.test("Should return nothing if dan is not a number", async (t) => {
const fn = await CreateKaiIIDXClassHandler(
const fn = await CreateKaiIIDXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -74,7 +74,7 @@ t.test("#CreateKaiIIDXClassHandler", async (t) => {
});
t.test("Should return nothing if dan is too great", async (t) => {
const fn = await CreateKaiIIDXClassHandler(
const fn = await CreateKaiIIDXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -102,7 +102,7 @@ t.test("#CreateKaiIIDXClassHandler", async (t) => {
});
t.test("Should return nothing if dan is negative", async (t) => {
const fn = await CreateKaiIIDXClassHandler(
const fn = await CreateKaiIIDXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -130,7 +130,7 @@ t.test("#CreateKaiIIDXClassHandler", async (t) => {
});
t.test("Should gracefully handle negative API responses", async (t) => {
const fn = await CreateKaiIIDXClassHandler(
const fn = await CreateKaiIIDXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -149,7 +149,7 @@ t.test("#CreateKaiIIDXClassHandler", async (t) => {
t.test("Should call reauthFn if statusCode is 401", async (t) => {
let pass = false;
const fn = await CreateKaiIIDXClassHandler(
const fn = await CreateKaiIIDXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -168,7 +168,7 @@ t.test("#CreateKaiIIDXClassHandler", async (t) => {
});
t.test("Should ignore null dans", async (t) => {
const fn = await CreateKaiIIDXClassHandler(
const fn = await CreateKaiIIDXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -196,7 +196,7 @@ t.test("#CreateKaiIIDXClassHandler", async (t) => {
});
t.test("Should handle invalid playtypes", async (t) => {
const fn = await CreateKaiIIDXClassHandler(
const fn = await CreateKaiIIDXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -3,14 +3,14 @@ import { IIDX_DANS } from "tachi-common";
import nodeFetch from "utils/fetch";
import { IsRecord } from "utils/misc";
import type { KaiAPIReauthFunction } from "../traverse-api";
import type { ClassHandler } from "lib/score-import/framework/profile-calculated-data/types";
import type { ClassProvider } from "lib/score-import/framework/profile-calculated-data/types";
export async function CreateKaiIIDXClassHandler(
export async function CreateKaiIIDXClassProvider(
kaiType: "EAG" | "FLO",
token: string,
reauthFn: KaiAPIReauthFunction,
fetch = nodeFetch
): Promise<ClassHandler> {
): Promise<ClassProvider> {
let json: unknown;
let err: unknown;
const baseUrl = KaiTypeToBaseURL(kaiType);
@@ -1,4 +1,4 @@
import { CreateKaiIIDXClassHandler } from "./class-handler";
import { CreateKaiIIDXClassProvider } from "./class-handler";
import { CreateKaiReauthFunction } from "../reauth";
import { TraverseKaiAPI } from "../traverse-api";
import { KaiTypeToBaseURL } from "../utils";
@@ -30,7 +30,7 @@ export async function ParseKaiIIDX(
context: {
service,
},
classHandler: await CreateKaiIIDXClassHandler(service, authDoc.token, reauthFn, fetch),
classProvider: await CreateKaiIIDXClassProvider(service, authDoc.token, reauthFn, fetch),
game: "iidx",
};
}
@@ -1,4 +1,4 @@
import { CreateKaiSDVXClassHandler } from "./class-handler";
import { CreateKaiSDVXClassProvider } from "./class-handler";
import { KaiTypeToBaseURL } from "../utils";
import CreateLogCtx from "lib/logger/logger";
import { SDVX_DANS } from "tachi-common";
@@ -8,10 +8,10 @@ import ResetDBState from "test-utils/resets";
const logger = CreateLogCtx(__filename);
t.test("#CreateKaiSDVXClassHandler", async (t) => {
t.test("#CreateKaiSDVXClassProvider", async (t) => {
t.beforeEach(ResetDBState);
const fn = await CreateKaiSDVXClassHandler(
const fn = await CreateKaiSDVXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -45,7 +45,7 @@ t.test("#CreateKaiSDVXClassHandler", async (t) => {
});
t.test("Should return nothing if dan is not a number", async (t) => {
const fn = await CreateKaiSDVXClassHandler(
const fn = await CreateKaiSDVXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -72,7 +72,7 @@ t.test("#CreateKaiSDVXClassHandler", async (t) => {
});
t.test("Should return nothing if dan is too great", async (t) => {
const fn = await CreateKaiSDVXClassHandler(
const fn = await CreateKaiSDVXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -99,7 +99,7 @@ t.test("#CreateKaiSDVXClassHandler", async (t) => {
});
t.test("Should return nothing if dan is negative", async (t) => {
const fn = await CreateKaiSDVXClassHandler(
const fn = await CreateKaiSDVXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -126,7 +126,7 @@ t.test("#CreateKaiSDVXClassHandler", async (t) => {
});
t.test("Should gracefully handle negative API responses", async (t) => {
const fn = await CreateKaiSDVXClassHandler(
const fn = await CreateKaiSDVXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -145,7 +145,7 @@ t.test("#CreateKaiSDVXClassHandler", async (t) => {
t.test("Should call reauthFn if statusCode is 401", async (t) => {
let pass = false;
const fn = await CreateKaiSDVXClassHandler(
const fn = await CreateKaiSDVXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -164,7 +164,7 @@ t.test("#CreateKaiSDVXClassHandler", async (t) => {
});
t.test("Should ignore null dans", async (t) => {
const fn = await CreateKaiSDVXClassHandler(
const fn = await CreateKaiSDVXClassProvider(
"FLO",
"token",
// eslint-disable-next-line @typescript-eslint/require-await
@@ -3,14 +3,14 @@ import { SDVX_DANS } from "tachi-common";
import nodeFetch from "utils/fetch";
import { IsRecord } from "utils/misc";
import type { KaiAPIReauthFunction } from "../traverse-api";
import type { ClassHandler } from "lib/score-import/framework/profile-calculated-data/types";
import type { ClassProvider } from "lib/score-import/framework/profile-calculated-data/types";
export async function CreateKaiSDVXClassHandler(
export async function CreateKaiSDVXClassProvider(
kaiType: "EAG" | "FLO" | "MIN",
token: string,
reauthFn: KaiAPIReauthFunction,
fetch = nodeFetch
): Promise<ClassHandler> {
): Promise<ClassProvider> {
let json: unknown;
let err: unknown;
const baseUrl = KaiTypeToBaseURL(kaiType);
@@ -1,4 +1,4 @@
import { CreateKaiSDVXClassHandler } from "./class-handler";
import { CreateKaiSDVXClassProvider } from "./class-handler";
import { CreateKaiReauthFunction } from "../reauth";
import { TraverseKaiAPI } from "../traverse-api";
import { KaiTypeToBaseURL } from "../utils";
@@ -30,7 +30,7 @@ export async function ParseKaiSDVX(
context: {
service,
},
classHandler: await CreateKaiSDVXClassHandler(service, authDoc.token, reauthFn, fetch),
classProvider: await CreateKaiSDVXClassProvider(service, authDoc.token, reauthFn, fetch),
game: "sdvx",
};
}
@@ -1,12 +1,12 @@
import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error";
import { GetGamePTConfig } from "tachi-common";
import type { ClassHandler } from "lib/score-import/framework/profile-calculated-data/types";
import type { ClassProvider } from "lib/score-import/framework/profile-calculated-data/types";
import type { GamePTConfig, GPTString, ClassRecords, Classes } from "tachi-common";
// Note: This is tested by batch-manuals parser.test.ts.
export function CreateBatchManualClassHandler(
export function CreateBatchManualClassProvider(
classes: Partial<Record<Classes[GPTString], string | null>>
): ClassHandler {
): ClassProvider {
return (game, playtype, userID, ratings, logger) => {
const gptConfig = GetGamePTConfig(game, playtype);
@@ -336,9 +336,9 @@ t.test("#ParserFn", (t) => {
logger
);
t.not(res.classHandler, null);
t.not(res.classProvider, null);
t.strictSame(res.classHandler!("iidx", "SP", 1, {}, logger), { dan: IIDX_DANS.KAIDEN });
t.strictSame(res.classProvider!("iidx", "SP", 1, {}, logger), { dan: IIDX_DANS.KAIDEN });
t.end();
});
@@ -354,7 +354,7 @@ t.test("#ParserFn", (t) => {
logger
);
t.equal(res.classHandler, null);
t.equal(res.classProvider, null);
t.end();
});
@@ -1,4 +1,4 @@
import { CreateBatchManualClassHandler } from "./class-handler";
import { CreateBatchManualClassProvider } from "./class-handler";
import ScoreImportFatalError from "../../../framework/score-importing/score-import-error";
import { TachiConfig } from "lib/setup/config";
import { p } from "prudence";
@@ -104,8 +104,8 @@ export function ParseBatchManualFromObject(
// if classes are provided, use those as a class handler. Otherwise, we
// don't care.
classHandler: batchManual.classes
? CreateBatchManualClassHandler(batchManual.classes)
classProvider: batchManual.classes
? CreateBatchManualClassProvider(batchManual.classes)
: null,
};
}
@@ -245,7 +245,7 @@ function GenericParseEamIIDXCSV(
iterable: iterableData,
context,
game: "iidx",
classHandler: null,
classProvider: null,
};
}
@@ -1,6 +1,5 @@
import type { ConverterFailure } from "../../framework/common/converter-failures";
import type { DryScore } from "../../framework/common/types";
import type { ClassHandler } from "../../framework/profile-calculated-data/types";
import type { SDVXEamusementCSVData } from "../file/eamusement-sdvx-csv/types";
import type { MerScore } from "../file/mer-iidx/types";
import type { S3Score } from "../file/solid-state-squad/types";
@@ -13,17 +12,12 @@ import type { KsHookSV6CStaticScore } from "../ir/kshook-sv6c-static/types";
import type { KsHookSV6CContext, KsHookSV6CScore } from "../ir/kshook-sv6c/types";
import type { LR2HookContext, LR2HookScore } from "../ir/lr2hook/types";
import type { IRUSCContext } from "../ir/usc/types";
import type {
CGContext,
CGJubeatScore,
CGMusecaScore,
CGPopnScore,
CGSDVXScore,
} from "./api-cg/types";
import type { CGContext, CGMusecaScore, CGPopnScore, CGSDVXScore } from "./api-cg/types";
import type { KaiContext } from "./api-kai/types";
import type { BatchManualContext } from "./batch-manual/types";
import type { IIDXEamusementCSVContext, IIDXEamusementCSVData } from "./eamusement-iidx-csv/types";
import type { KtLogger } from "lib/logger/logger";
import type { ClassProvider } from "lib/score-import/framework/calculated-data/types";
import type { USCClientScore } from "server/router/ir/usc/_playtype/types";
import type {
BatchManualScore,
@@ -31,7 +25,6 @@ import type {
Game,
ImportTypes,
integer,
MongoDBDocument,
SongDocument,
} from "tachi-common";
import type { EmptyObject } from "utils/types";
@@ -113,7 +106,7 @@ export interface ImportTypeContextMap {
"api/cg-prod-popn": CGContext;
}
export interface OrphanScoreDocument<T extends ImportTypes = ImportTypes> extends MongoDBDocument {
export interface OrphanScoreDocument<T extends ImportTypes = ImportTypes> {
importType: T;
data: ImportTypeDataMap[T];
context: ImportTypeContextMap[T];
@@ -147,7 +140,7 @@ export interface ParserFunctionReturns<D, C> {
iterable: AsyncIterable<D> | Iterable<D>;
context: C;
game: Game;
classHandler: ClassHandler | null;
classProvider: ClassProvider | null;
}
export type ParserFunction<D, C, A extends Array<unknown>> = (
@@ -66,6 +66,6 @@ export default function ParseEamusementSDVXCSV(
iterable,
context: {},
game: "sdvx",
classHandler: null,
classProvider: null,
};
}
@@ -35,7 +35,7 @@ t.test("#ParseMerIIDX", (t) => {
{
game: "iidx",
context: {},
classHandler: null,
classProvider: null,
iterable: [
{
music_id: 3007,
@@ -53,7 +53,7 @@ export function ParseMerIIDX(
}
return {
classHandler: null,
classProvider: null,
context: {},
iterable: jsonData as Array<MerScore>,
game: "iidx",
@@ -60,7 +60,7 @@ t.test("#ParseSolidStateXML", (t) => {
"Should return the right scores in the iterable."
);
t.equal(res.game, "iidx", "Should return IIDX as the game.");
t.equal(res.classHandler, null, "Should return no class handler.");
t.equal(res.classProvider, null, "Should return no class handler.");
t.strictSame(res.context, {}, "Should return no context.");
t.end();
@@ -97,7 +97,7 @@ t.test("#ParseSolidStateXML", (t) => {
"Should return the right score in the iterable."
);
t.equal(res.game, "iidx", "Should return IIDX as the game.");
t.equal(res.classHandler, null, "Should return no class handler.");
t.equal(res.classProvider, null, "Should return no class handler.");
t.strictSame(res.context, {}, "Should return no context.");
t.end();
@@ -134,7 +134,7 @@ t.test("#ParseSolidStateXML", (t) => {
"Should return the right score in the iterable."
);
t.equal(res.game, "iidx", "Should return IIDX as the game.");
t.equal(res.classHandler, null, "Should return no class handler.");
t.equal(res.classProvider, null, "Should return no class handler.");
t.strictSame(res.context, {}, "Should return no context.");
t.end();
@@ -132,7 +132,7 @@ export function ParseSolidStateXML(
}));
return {
classHandler: null,
classProvider: null,
context: {},
iterable: scoreData as Array<S3Score>,
game: "iidx",
@@ -1,6 +1,6 @@
import ScoreImportFatalError from "../../../framework/score-importing/score-import-error";
import { WACCA_STAGEUPS } from "tachi-common";
import type { ClassHandler } from "../../../framework/profile-calculated-data/types";
import type { ClassProvider } from "../../../framework/ugpt-stats/types";
import type { MyPagePlayerStage } from "./types";
const STAGES: Record<number, number> = {
@@ -38,7 +38,7 @@ const STAGE_NAMES: Record<number, string> = {
14: "XIV",
};
export function CreateMyPageScraperClassHandler(stage: MyPagePlayerStage): ClassHandler {
export function CreateMyPageScraperClassProvider(stage: MyPagePlayerStage): ClassProvider {
return (_game, _playtype, _userID, _ratings, logger) => {
const stageName = STAGE_NAMES[stage.id];
@@ -122,17 +122,17 @@ t.test("#ParseMyPageScraperPlayerCSV", (t) => {
const file = MockMulterFile(buffer, "player.csv");
const { iterable, game, classHandler } = ParseMyPageScraperPlayerCSV(file, {}, logger);
const { iterable, game, classProvider: classProvider } = ParseMyPageScraperPlayerCSV(file, {}, logger);
t.equal(game, "wacca");
t.strictSame(iterable, [], "Should not have any score data.");
t.not(classHandler, null);
t.not(classProvider, null);
// There's no good way to test that the classHandler got a valid
// There's no good way to test that the classProvider got a valid
// MyPagePlayerStage, so we just call it to see.
t.strictSame(classHandler!("wacca", "Single", 0, {}, logger), {
t.strictSame(classProvider!("wacca", "Single", 0, {}, logger), {
stageUp: WACCA_STAGEUPS.XII,
});
@@ -1,4 +1,4 @@
import { CreateMyPageScraperClassHandler } from "./class-handler";
import { CreateMyPageScraperClassProvider } from "./class-handler";
import ScoreImportFatalError from "../../../framework/score-importing/score-import-error";
import { parse, CsvError as CSVError } from "csv-parse/sync";
import { p } from "prudence";
@@ -111,7 +111,7 @@ export function ParseMyPageScraperRecordsCSV(
iterable,
context: {},
game: "wacca",
classHandler: null,
classProvider: null,
};
}
@@ -169,6 +169,6 @@ export function ParseMyPageScraperPlayerCSV(
iterable: [],
context: {},
game: "wacca",
classHandler: CreateMyPageScraperClassHandler(stage),
classProvider: CreateMyPageScraperClassProvider(stage),
};
}
@@ -75,6 +75,6 @@ export function ParseBarbatosSingle(
},
game: "sdvx",
iterable: [body] as unknown as Array<BarbatosScore | BarbatosSDVX6Score>,
classHandler: null,
classProvider: null,
};
}
@@ -126,6 +126,6 @@ export function ParseBeatorajaSingle(
},
game: isPMS ? "pms" : "bms",
iterable: [score],
classHandler: null,
classProvider: null,
};
}
@@ -1,4 +1,4 @@
import { CreateFerStaticClassHandler } from "./class-handler";
import { CreateFerStaticClassProvider } from "./class-handler";
import CreateLogCtx from "lib/logger/logger";
import t from "tap";
import ResetDBState from "test-utils/resets";
@@ -6,11 +6,11 @@ import type { Playtype } from "tachi-common";
const logger = CreateLogCtx(__filename);
t.test("#FerStaticClassHandler", (t) => {
t.test("#FerStaticClassProvider", (t) => {
t.beforeEach(ResetDBState);
t.test("Should curry a function", (t) => {
const res = CreateFerStaticClassHandler({ sp_dan: 1 });
const res = CreateFerStaticClassProvider({ sp_dan: 1 });
t.equal(typeof res, "function");
@@ -18,7 +18,7 @@ t.test("#FerStaticClassHandler", (t) => {
});
t.test("Should work with no dans", (t) => {
const res = CreateFerStaticClassHandler({})("iidx", "SP", 1, {}, logger);
const res = CreateFerStaticClassProvider({})("iidx", "SP", 1, {}, logger);
t.equal(res, undefined, "Should return nothing.");
@@ -26,7 +26,7 @@ t.test("#FerStaticClassHandler", (t) => {
});
t.test("Should update the same dan as the playtype.", (t) => {
const fn = CreateFerStaticClassHandler({ sp_dan: 5, dp_dan: 7 });
const fn = CreateFerStaticClassProvider({ sp_dan: 5, dp_dan: 7 });
const res = fn("iidx", "SP", 1, {}, logger);
t.strictSame(res, { dan: 5 }, "Should return SP dan's value.");
@@ -39,7 +39,7 @@ t.test("#FerStaticClassHandler", (t) => {
});
t.test("Should skip if dan is invalid.", (t) => {
const fn = CreateFerStaticClassHandler({ sp_dan: -1, dp_dan: 100 });
const fn = CreateFerStaticClassProvider({ sp_dan: -1, dp_dan: 100 });
const res = fn("iidx", "SP", 1, {}, logger);
t.equal(res, undefined, "Should skip SP dan's value.");
@@ -52,7 +52,7 @@ t.test("#FerStaticClassHandler", (t) => {
});
t.test("Should skip if playtype is invalid", (t) => {
const fn = CreateFerStaticClassHandler({ sp_dan: 5, dp_dan: 7 });
const fn = CreateFerStaticClassProvider({ sp_dan: 5, dp_dan: 7 });
const res = fn("iidx", "INVALID" as Playtype, 1, {}, logger);
t.equal(res, undefined, "Should skip over as a failsafe.");
@@ -1,17 +1,17 @@
import type { ClassHandler } from "../../../framework/profile-calculated-data/types";
import type { ClassProvider } from "lib/score-import/framework/calculated-data/types";
import type { integer } from "tachi-common";
export function CreateFerStaticClassHandler(body: Record<string, unknown>): ClassHandler {
return (game, playtype, userID, ratings, logger) => {
export function CreateFerStaticClassProvider(body: Record<string, unknown>): ClassProvider {
return (gptString, userID, ratings, logger) => {
let index;
if (playtype === "SP") {
if (gptString === "iidx:SP") {
index = body.sp_dan;
} else if (playtype === "DP") {
} else if (gptString === "iidx:DP") {
index = body.dp_dan;
} else {
logger.warn(
`Invalid playtype ${playtype} passed to FerStaticClassHandler. Attempting to continue.`
`Invalid gptString ${gptString} passed to FerStaticClassProvider. Attempting to continue.`
);
return;
}
@@ -21,7 +21,7 @@ export function CreateFerStaticClassHandler(body: Record<string, unknown>): Clas
}
if (!Number.isInteger(index)) {
logger.info(`received invalid fer-static class of ${index} (${playtype}).`, { body });
logger.info(`received invalid fer-static class of ${index} (${gptString}).`, { body });
return;
}
@@ -45,7 +45,7 @@ t.test("#ParseFervidexStatic", (t) => {
game: "iidx",
});
t.type(res.classHandler, "function", "Should have a function ready for class handling");
t.type(res.classProvider, "function", "Should have a function ready for class handling");
t.end();
});
@@ -65,7 +65,7 @@ t.test("#ParseFervidexStatic", (t) => {
});
t.type(
res.classHandler,
res.classProvider,
"function",
"Should have a function ready for class handling, regardless of shouldImportScores being false"
);
@@ -1,4 +1,4 @@
import { CreateFerStaticClassHandler } from "./class-handler";
import { CreateFerStaticClassProvider } from "./class-handler";
import { AssertStrAsPositiveInt } from "../../../framework/common/string-asserts";
import ScoreImportFatalError from "../../../framework/score-importing/score-import-error";
import { SoftwareIDToVersion } from "../fervidex/parser";
@@ -22,7 +22,7 @@ export function ParseFervidexStatic(
logger: KtLogger
): ParserFunctionReturns<FervidexStaticScore, FervidexStaticContext> {
const version = SoftwareIDToVersion(headers.model, logger);
const classHandler = CreateFerStaticClassHandler(body);
const classProvider = CreateFerStaticClassProvider(body);
// if we shouldn't import scores, just sync up dans.
if (!headers.shouldImportScores) {
@@ -30,7 +30,7 @@ export function ParseFervidexStatic(
context: { version },
game: "iidx",
iterable: [],
classHandler,
classProvider,
};
}
@@ -90,6 +90,6 @@ export function ParseFervidexStatic(
context: { version },
game: "iidx",
iterable: scores,
classHandler,
classProvider,
};
}
@@ -150,6 +150,6 @@ export function ParseFervidexSingle(
context: { version, timeReceived: Date.now(), userID },
game: "iidx",
iterable: [score],
classHandler: null,
classProvider: null,
};
}
@@ -41,6 +41,6 @@ export function ParseKsHookSV6CStatic(
game: "sdvx",
iterable: data.scores,
context: {},
classHandler: null,
classProvider: null,
};
}
@@ -69,6 +69,6 @@ export function ParseKsHookSV6C(
context: {
timeReceived: Date.now(),
},
classHandler: null,
classProvider: null,
};
}
@@ -22,7 +22,7 @@ t.test("#ParseLR2Hook", (t) => {
t.equal(res.game, "bms");
t.type(res.context.timeReceived, "number");
t.ok(Array.isArray(res.iterable));
t.equal(res.classHandler, null);
t.equal(res.classProvider, null);
} catch (err) {
t.fail(`[${message}] ${(err as Error).message}`);
}
@@ -83,6 +83,6 @@ export function ParseLR2Hook(
context: {
timeReceived: Date.now(),
},
classHandler: null,
classProvider: null,
};
}
@@ -67,6 +67,6 @@ export function ParseIRUSC(
},
game: "usc",
iterable: [score] as Array<USCClientScore>,
classHandler: null,
classProvider: null,
};
}
@@ -5,7 +5,7 @@ import { GetAndUpdateUsersGoals } from "lib/score-import/framework/goals/goals";
import { UpdateChartRanking } from "lib/score-import/framework/pb/create-pb-doc";
import { ProcessPBs } from "lib/score-import/framework/pb/process-pbs";
import { UpdateUsersQuests } from "lib/score-import/framework/quests/quests";
import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/profile-calculated-data/update-ugs";
import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/ugpt-stats/update-ugpt-stats";
import { RecalcSessions } from "utils/calculations/recalc-sessions";
import type { Game, Playtype, ScoreDocument } from "tachi-common";
+15 -5
View File
@@ -1,10 +1,9 @@
import db from "external/mongo/db";
import { GenericCalculatePercent } from "lib/score-import/framework/common/score-utils";
import { FormatGame, GetGamePTConfig } from "tachi-common";
import { FormatGame, GetGamePTConfig, GetSpecificGPTConfig } from "tachi-common";
import { GetFolderForIDGuaranteed, HumaniseChartID } from "utils/db";
import { GetFolderChartIDs } from "utils/folder";
import { FormatMaxDP, HumanisedJoinArray } from "utils/misc";
import type { ChartDocument, Game, GoalDocument, Playtype } from "tachi-common";
import type { ChartDocument, Game, GoalDocument, Playtype, GPTString } from "tachi-common";
export async function CreateGoalTitle(
charts: GoalDocument["charts"],
@@ -119,8 +118,19 @@ async function FormatCharts(
}
}
function FormatCriteria(criteria: GoalDocument["criteria"], game: Game, playtype: Playtype) {
const gptConfig = GetGamePTConfig(game, playtype);
function FormatCriteria<GPT extends GPTString>(
criteria: GoalDocument<GPT>["criteria"],
gptString: GPT
) {
const gptConfig = GetSpecificGPTConfig(gptString);
switch (criteria.key) {
case "":
break;
default:
break;
}
switch (criteria.key) {
case "scoreData.gradeIndex":
@@ -1,7 +1,7 @@
/* eslint-disable no-await-in-loop */
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/profile-calculated-data/update-ugs";
import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/ugpt-stats/update-ugpt-stats";
import { WrapScriptPromise } from "utils/misc";
import { FormatUserDoc } from "utils/user";
import type { Game, Playtype, ScoreDocument } from "tachi-common";
+16 -2
View File
@@ -8,11 +8,12 @@ import { SoftwareIDToVersion } from "lib/score-import/import-types/ir/fervidex/p
import { p } from "prudence";
import { RequirePermissions } from "server/middleware/auth";
import { PrudenceErrorFormatter } from "server/middleware/prudence-validate";
import { GetGamePTConfig } from "tachi-common";
import { UpdateClassIfGreater } from "utils/class";
import { ParseEA3SoftID } from "utils/ea3id";
import { IsNullishOrEmptyStr } from "utils/misc";
import type { RequestHandler } from "express";
import type { integer, Playtypes } from "tachi-common";
import type { Playtypes, SpecificGamePTConfig, integer } from "tachi-common";
const logger = CreateLogCtx(__filename);
@@ -316,12 +317,25 @@ router.post("/class/submit", ValidateModelHeader, async (req, res) => {
// is 0 or 1.
const playtype: Playtypes["iidx"] = body.play_style === 0 ? "SP" : "DP";
const dans = (
GetGamePTConfig("iidx", playtype) as unknown as SpecificGamePTConfig<"iidx:DP" | "iidx:SP">
).classes.dan.values;
const dan = dans[body.course_id];
if (dan === undefined) {
return res.status(400).json({
success: false,
description: `Unknown course_id ${body.course_id}.`,
});
}
const r = await UpdateClassIfGreater(
req[SYMBOL_TACHI_API_AUTH].userID!,
"iidx",
playtype,
"dan",
body.course_id
dan.id
);
return res.status(200).json({
@@ -2,7 +2,7 @@
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/profile-calculated-data/update-ugs";
import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/ugpt-stats/update-ugpt-stats";
import { FormatUserDoc } from "utils/user";
const logger = CreateLogCtx(__filename);
@@ -6,7 +6,7 @@ import { CreateCalculatedData } from "lib/score-import/framework/score-calculate
import { GetAndUpdateUsersGoals } from "lib/score-import/framework/goals/goals";
import { ProcessPBs } from "lib/score-import/framework/pb/process-pbs";
import { UpdateUsersQuests } from "lib/score-import/framework/quests/quests";
import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/profile-calculated-data/update-ugs";
import { UpdateUsersGamePlaytypeStats } from "lib/score-import/framework/ugpt-stats/update-ugpt-stats";
import { TachiConfig } from "lib/setup/config";
import { GetGameConfig } from "tachi-common";
import { EfficientDBIterate } from "utils/efficient-db-iterate";
+55 -7
View File
@@ -2,8 +2,8 @@ import db from "external/mongo/db";
import { CreateGameSettings } from "lib/game-settings/create-game-settings";
import CreateLogCtx from "lib/logger/logger";
import { EmitWebhookEvent } from "lib/webhooks/webhooks";
import type { Game, GPTString, integer, Playtype, UserGameStats } from "tachi-common";
import type { Classes } from "tachi-common/game-classes";
import { GetGPTConfig, GetGPTString } from "tachi-common";
import type { Game, GPTString, integer, Playtype, UserGameStats, Classes } from "tachi-common";
const logger = CreateLogCtx(__filename);
@@ -13,15 +13,61 @@ const logger = CreateLogCtx(__filename);
* to compare to, and FALSE if it is worse or equal.
*/
export function ReturnClassIfGreater(
gptString: GPTString,
classSet: Classes[GPTString],
classVal: integer,
classVal: string,
userGameStats?: UserGameStats | null
) {
if (!userGameStats || userGameStats.classes[classSet] === undefined) {
const gptConfig = GetGPTConfig(gptString);
const classInfo = gptConfig.classes[classSet];
if (!classInfo) {
logger.warn(
`Invalid ReturnClassIfGreater call. Attempted to index set '${classSet}' on ${gptString}. No such class is defined for this game.`
);
return null;
}
return classVal > userGameStats.classes[classSet]!;
if (!userGameStats) {
return null;
}
const prevClass: string | null | undefined = userGameStats.classes[classSet];
if (prevClass === null || prevClass === undefined) {
return null;
}
const previousClassIndex = ClassToIndex(gptString, classSet, prevClass);
const newClassIndex = ClassToIndex(gptString, classSet, classVal);
return newClassIndex > previousClassIndex;
}
export function ClassToIndex(gptString: GPTString, classSet: Classes[GPTString], classVal: string) {
const gptConfig = GetGPTConfig(gptString);
const classInfo = gptConfig.classes[classSet];
if (!classInfo) {
logger.warn(
`Invalid ClassToIndex call. Attempted to index set '${classSet}' on ${gptString}. No such class is defined for this game. Returning -1.`
);
return -1;
}
const v = classInfo.values.map((e) => e.id).indexOf(classVal);
if (v === -1) {
logger.warn(
`Attempted to index a class that doesn't exist: ${classVal} on ${classSet} (${gptString}). Returning -1.`
);
}
return v;
}
/**
@@ -37,10 +83,12 @@ export async function UpdateClassIfGreater(
game: Game,
playtype: Playtype,
classSet: Classes[GPTString],
classVal: integer
classVal: string
) {
const gptString = GetGPTString(game, playtype);
const userGameStats = await db["game-stats"].findOne({ userID, game, playtype });
const isGreater = ReturnClassIfGreater(classSet, classVal, userGameStats);
const isGreater = ReturnClassIfGreater(gptString, classSet, classVal, userGameStats);
if (isGreater === false) {
return false;