Convert space indentation to tabs.

This commit is contained in:
zkldi
2021-06-20 03:13:05 +01:00
parent e36ee27ca4
commit b6cee346b9
233 changed files with 20954 additions and 20952 deletions
+2 -1
View File
@@ -31,7 +31,8 @@
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"endOfLine": "auto"
"endOfLine": "auto",
"useTabs": true
}
],
"@typescript-eslint/explicit-module-boundary-types": "off",
+1
View File
@@ -0,0 +1 @@
#188 - Spaces to tabs
+2874 -2874
View File
File diff suppressed because it is too large Load Diff
+96 -96
View File
@@ -1,30 +1,30 @@
import {
CounterDocument,
FolderDocument,
GoalDocument,
IIDXBPIData,
IIDXEamusementScoreDocument,
ImportDocument,
TierlistParent,
InviteCodeDocument,
APITokenDocument,
TierlistDataDocument,
MilestoneDocument,
NotificationDocument,
FolderChartLookup,
ImportTimingsDocument,
PrivateUserDocument,
ScoreDocument,
KaiAuthDocument,
SessionDocument,
AnySongDocument,
AnyChartDocument,
UserGameStats,
UserGoalDocument,
PBScoreDocument,
UserMilestoneDocument,
BMSCourseDocument,
ImportLockDocument,
CounterDocument,
FolderDocument,
GoalDocument,
IIDXBPIData,
IIDXEamusementScoreDocument,
ImportDocument,
TierlistParent,
InviteCodeDocument,
APITokenDocument,
TierlistDataDocument,
MilestoneDocument,
NotificationDocument,
FolderChartLookup,
ImportTimingsDocument,
PrivateUserDocument,
ScoreDocument,
KaiAuthDocument,
SessionDocument,
AnySongDocument,
AnyChartDocument,
UserGameStats,
UserGoalDocument,
PBScoreDocument,
UserMilestoneDocument,
BMSCourseDocument,
ImportLockDocument,
} from "tachi-common";
import monk from "monk";
import { MONGO_CONNECTION_URL, MONGO_DATABASE_NAME } from "../../lib/setup/config";
@@ -37,98 +37,98 @@ let dbName = MONGO_DATABASE_NAME;
/* istanbul ignore next */
if (process.env.NODE_ENV === "test") {
if (process.env.TACHI_PARALLEL_TESTS) {
dbName = `test-ephemeral-${process.pid}`;
} else {
dbName = `testingdb`;
}
if (process.env.TACHI_PARALLEL_TESTS) {
dbName = `test-ephemeral-${process.pid}`;
} else {
dbName = `testingdb`;
}
}
let dbtime: [number, number] = [0, 0];
/* istanbul ignore next */
if (process.env.NODE_ENV !== "test") {
logger.info(`Connecting to database ${MONGO_CONNECTION_URL}/${dbName}...`);
dbtime = process.hrtime();
logger.info(`Connecting to database ${MONGO_CONNECTION_URL}/${dbName}...`);
dbtime = process.hrtime();
}
export const monkDB = monk(`${MONGO_CONNECTION_URL}/${dbName}`);
/* istanbul ignore next */
monkDB
.then(() => {
if (process.env.NODE_ENV !== "test") {
const time = process.hrtime(dbtime);
const elapsed = time[0] + time[1] / 1e6;
logger.info(`Database connection successful: took ${elapsed}ms`);
}
})
.catch((err) => {
logger.crit(err);
process.exit(1);
});
.then(() => {
if (process.env.NODE_ENV !== "test") {
const time = process.hrtime(dbtime);
const elapsed = time[0] + time[1] / 1e6;
logger.info(`Database connection successful: took ${elapsed}ms`);
}
})
.catch((err) => {
logger.crit(err);
process.exit(1);
});
export async function CloseMongoConnection() {
await monkDB.close();
await monkDB.close();
}
const songs = {
bms: monkDB.get<AnySongDocument>(`songs-bms`),
chunithm: monkDB.get<AnySongDocument>(`songs-chunithm`),
ddr: monkDB.get<AnySongDocument>(`songs-ddr`),
gitadora: monkDB.get<AnySongDocument>(`songs-gitadora`),
iidx: monkDB.get<AnySongDocument>(`songs-iidx`),
jubeat: monkDB.get<AnySongDocument>(`songs-jubeat`),
maimai: monkDB.get<AnySongDocument>(`songs-maimai`),
museca: monkDB.get<AnySongDocument>(`songs-museca`),
popn: monkDB.get<AnySongDocument>(`songs-popn`),
sdvx: monkDB.get<AnySongDocument>(`songs-sdvx`),
usc: monkDB.get<AnySongDocument>(`songs-usc`),
bms: monkDB.get<AnySongDocument>(`songs-bms`),
chunithm: monkDB.get<AnySongDocument>(`songs-chunithm`),
ddr: monkDB.get<AnySongDocument>(`songs-ddr`),
gitadora: monkDB.get<AnySongDocument>(`songs-gitadora`),
iidx: monkDB.get<AnySongDocument>(`songs-iidx`),
jubeat: monkDB.get<AnySongDocument>(`songs-jubeat`),
maimai: monkDB.get<AnySongDocument>(`songs-maimai`),
museca: monkDB.get<AnySongDocument>(`songs-museca`),
popn: monkDB.get<AnySongDocument>(`songs-popn`),
sdvx: monkDB.get<AnySongDocument>(`songs-sdvx`),
usc: monkDB.get<AnySongDocument>(`songs-usc`),
};
const charts = {
bms: monkDB.get<AnyChartDocument>(`charts-bms`),
chunithm: monkDB.get<AnyChartDocument>(`charts-chunithm`),
ddr: monkDB.get<AnyChartDocument>(`charts-ddr`),
gitadora: monkDB.get<AnyChartDocument>(`charts-gitadora`),
iidx: monkDB.get<AnyChartDocument>(`charts-iidx`),
jubeat: monkDB.get<AnyChartDocument>(`charts-jubeat`),
maimai: monkDB.get<AnyChartDocument>(`charts-maimai`),
museca: monkDB.get<AnyChartDocument>(`charts-museca`),
popn: monkDB.get<AnyChartDocument>(`charts-popn`),
sdvx: monkDB.get<AnyChartDocument>(`charts-sdvx`),
usc: monkDB.get<AnyChartDocument>(`charts-usc`),
bms: monkDB.get<AnyChartDocument>(`charts-bms`),
chunithm: monkDB.get<AnyChartDocument>(`charts-chunithm`),
ddr: monkDB.get<AnyChartDocument>(`charts-ddr`),
gitadora: monkDB.get<AnyChartDocument>(`charts-gitadora`),
iidx: monkDB.get<AnyChartDocument>(`charts-iidx`),
jubeat: monkDB.get<AnyChartDocument>(`charts-jubeat`),
maimai: monkDB.get<AnyChartDocument>(`charts-maimai`),
museca: monkDB.get<AnyChartDocument>(`charts-museca`),
popn: monkDB.get<AnyChartDocument>(`charts-popn`),
sdvx: monkDB.get<AnyChartDocument>(`charts-sdvx`),
usc: monkDB.get<AnyChartDocument>(`charts-usc`),
};
const db = {
// i have to handwrite this out for TS... :(
// dont worry, it was all macro'd
songs,
charts,
scores: monkDB.get<ScoreDocument>("scores"),
tierlists: monkDB.get<TierlistParent>("tierlists"),
"tierlist-data": monkDB.get<TierlistDataDocument<never>>("tierlist-data"),
"personal-bests": monkDB.get<PBScoreDocument>("personal-bests"),
folders: monkDB.get<FolderDocument>("folders"),
"folder-chart-lookup": monkDB.get<FolderChartLookup>("folder-chart-lookup"),
goals: monkDB.get<GoalDocument>("goals"),
"user-goals": monkDB.get<UserGoalDocument>("user-goals"),
milestones: monkDB.get<MilestoneDocument>("milestones"),
"user-milestones": monkDB.get<UserMilestoneDocument>("user-milestones"),
users: monkDB.get<PrivateUserDocument>("users"),
imports: monkDB.get<ImportDocument>("imports"),
"import-timings": monkDB.get<ImportTimingsDocument>("import-timings"),
notifications: monkDB.get<NotificationDocument>("notifications"),
sessions: monkDB.get<SessionDocument>("sessions"),
"iidx-bpi-data": monkDB.get<IIDXBPIData>("iidx-bpi-data"),
invites: monkDB.get<InviteCodeDocument>("invites"),
counters: monkDB.get<CounterDocument>("counters"),
"iidx-eam-scores": monkDB.get<IIDXEamusementScoreDocument>("iidx-eam-scores"),
"game-stats": monkDB.get<UserGameStats>("game-stats"),
"kai-auth-tokens": monkDB.get<KaiAuthDocument>("kai-auth-tokens"),
"bms-course-lookup": monkDB.get<BMSCourseDocument>("bms-course-lookup"),
"api-tokens": monkDB.get<APITokenDocument>("api-tokens"),
"orphan-scores": monkDB.get<OrphanScoreDocument>("orphan-scores"),
"import-locks": monkDB.get<ImportLockDocument>("import-locks"),
// i have to handwrite this out for TS... :(
// dont worry, it was all macro'd
songs,
charts,
scores: monkDB.get<ScoreDocument>("scores"),
tierlists: monkDB.get<TierlistParent>("tierlists"),
"tierlist-data": monkDB.get<TierlistDataDocument<never>>("tierlist-data"),
"personal-bests": monkDB.get<PBScoreDocument>("personal-bests"),
folders: monkDB.get<FolderDocument>("folders"),
"folder-chart-lookup": monkDB.get<FolderChartLookup>("folder-chart-lookup"),
goals: monkDB.get<GoalDocument>("goals"),
"user-goals": monkDB.get<UserGoalDocument>("user-goals"),
milestones: monkDB.get<MilestoneDocument>("milestones"),
"user-milestones": monkDB.get<UserMilestoneDocument>("user-milestones"),
users: monkDB.get<PrivateUserDocument>("users"),
imports: monkDB.get<ImportDocument>("imports"),
"import-timings": monkDB.get<ImportTimingsDocument>("import-timings"),
notifications: monkDB.get<NotificationDocument>("notifications"),
sessions: monkDB.get<SessionDocument>("sessions"),
"iidx-bpi-data": monkDB.get<IIDXBPIData>("iidx-bpi-data"),
invites: monkDB.get<InviteCodeDocument>("invites"),
counters: monkDB.get<CounterDocument>("counters"),
"iidx-eam-scores": monkDB.get<IIDXEamusementScoreDocument>("iidx-eam-scores"),
"game-stats": monkDB.get<UserGameStats>("game-stats"),
"kai-auth-tokens": monkDB.get<KaiAuthDocument>("kai-auth-tokens"),
"bms-course-lookup": monkDB.get<BMSCourseDocument>("bms-course-lookup"),
"api-tokens": monkDB.get<APITokenDocument>("api-tokens"),
"orphan-scores": monkDB.get<OrphanScoreDocument>("orphan-scores"),
"import-locks": monkDB.get<ImportLockDocument>("import-locks"),
};
export default db;
+103 -103
View File
@@ -8,132 +8,132 @@ import { CONF_INFO } from "../../lib/setup/config";
const logger = CreateLogCtx(__filename);
interface Index {
fields: Record<string, unknown>;
options?: IndexOptions;
fields: Record<string, unknown>;
options?: IndexOptions;
}
function index(fields: Record<string, unknown>, options?: IndexOptions) {
return { fields, options };
return { fields, options };
}
const UNIQUE = { unique: true };
const staticIndexes: Partial<Record<ValidDatabases, Index[]>> = {
scores: [index({ scoreID: 1 }, UNIQUE)],
"personal-bests": [
index({ chartID: 1, userID: 1 }, UNIQUE),
index({ chartID: 1, "scoreData.percent": 1 }),
],
sessions: [
// lol
index({ timeStarted: 1, timeEnded: 1, userID: 1, game: 1, playtype: 1 }),
index({ name: "text" }),
],
"game-stats": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE)],
"folder-chart-lookup": [index({ chartID: 1, folderID: 1 }, UNIQUE)],
"tierlist-data": [
index({ tierlistDataID: 1 }, UNIQUE),
index({ chartID: 1, tierlistID: 1 }),
index({ chartID: 1, tierlistID: 1, type: 1 }),
index({ chartID: 1, tierlistID: 1, type: 1, key: 1 }, UNIQUE),
],
goals: [index({ goalID: 1 }, UNIQUE)],
"user-goals": [index({ goalID: 1, userID: 1 }, UNIQUE), index({ goalID: 1 })],
milestones: [index({ milestoneID: 1 }, UNIQUE), index({ group: 1, game: 1, playtype: 1 })],
"user-milestones": [
index({ milestoneID: 1, userID: 1 }, UNIQUE),
index({ userID: 1, game: 1, playtype: 1 }),
],
imports: [index({ importID: 1 }, UNIQUE)],
"import-timings": [
index({ importID: 1 }, UNIQUE),
index({ timestamp: 1 }),
index({ total: 1 }),
],
users: [index({ id: 1 }, UNIQUE)],
tierlists: [
index({ tierlistID: 1 }, UNIQUE),
index({ game: 1, playtype: 1, isDefault: 1 }, UNIQUE),
],
folders: [
index({ folderID: 1 }, UNIQUE),
index({ game: 1, playtype: 1 }),
index({ game: 1, playtype: 1, table: 1 }),
index({ game: 1, playtype: 1, table: 1, tableIndex: 1 }),
],
"kai-auth-tokens": [index({ userID: 1, service: 1 }, UNIQUE)],
"charts-iidx": [
index(
{ "data.arcChartID": 1 },
{ unique: true, partialFilterExpression: { "data.arcChartID": { $type: "string" } } }
),
index({ "data.hashSHA256": 1 }),
],
"bms-course-lookup": [index({ md5sums: 1 }, UNIQUE)],
"api-tokens": [index({ token: 1 }, UNIQUE)],
scores: [index({ scoreID: 1 }, UNIQUE)],
"personal-bests": [
index({ chartID: 1, userID: 1 }, UNIQUE),
index({ chartID: 1, "scoreData.percent": 1 }),
],
sessions: [
// lol
index({ timeStarted: 1, timeEnded: 1, userID: 1, game: 1, playtype: 1 }),
index({ name: "text" }),
],
"game-stats": [index({ userID: 1, game: 1, playtype: 1 }, UNIQUE)],
"folder-chart-lookup": [index({ chartID: 1, folderID: 1 }, UNIQUE)],
"tierlist-data": [
index({ tierlistDataID: 1 }, UNIQUE),
index({ chartID: 1, tierlistID: 1 }),
index({ chartID: 1, tierlistID: 1, type: 1 }),
index({ chartID: 1, tierlistID: 1, type: 1, key: 1 }, UNIQUE),
],
goals: [index({ goalID: 1 }, UNIQUE)],
"user-goals": [index({ goalID: 1, userID: 1 }, UNIQUE), index({ goalID: 1 })],
milestones: [index({ milestoneID: 1 }, UNIQUE), index({ group: 1, game: 1, playtype: 1 })],
"user-milestones": [
index({ milestoneID: 1, userID: 1 }, UNIQUE),
index({ userID: 1, game: 1, playtype: 1 }),
],
imports: [index({ importID: 1 }, UNIQUE)],
"import-timings": [
index({ importID: 1 }, UNIQUE),
index({ timestamp: 1 }),
index({ total: 1 }),
],
users: [index({ id: 1 }, UNIQUE)],
tierlists: [
index({ tierlistID: 1 }, UNIQUE),
index({ game: 1, playtype: 1, isDefault: 1 }, UNIQUE),
],
folders: [
index({ folderID: 1 }, UNIQUE),
index({ game: 1, playtype: 1 }),
index({ game: 1, playtype: 1, table: 1 }),
index({ game: 1, playtype: 1, table: 1, tableIndex: 1 }),
],
"kai-auth-tokens": [index({ userID: 1, service: 1 }, UNIQUE)],
"charts-iidx": [
index(
{ "data.arcChartID": 1 },
{ unique: true, partialFilterExpression: { "data.arcChartID": { $type: "string" } } }
),
index({ "data.hashSHA256": 1 }),
],
"bms-course-lookup": [index({ md5sums: 1 }, UNIQUE)],
"api-tokens": [index({ token: 1 }, UNIQUE)],
};
const indexes: Partial<Record<ValidDatabases, Index[]>> = staticIndexes;
for (const game of CONF_INFO.SUPPORTED_GAMES) {
if (indexes[`charts-${game}` as ValidDatabases]) {
indexes[`charts-${game}` as ValidDatabases]!.push(
index({ chartID: 1 }, UNIQUE),
index(
{ songID: 1, difficulty: 1, playtype: 1, isPrimary: 1 },
{ unique: true, partialFilterExpression: { isPrimary: { $eq: true } } }
)
);
} else {
indexes[`charts-${game}` as ValidDatabases] = [
index({ chartID: 1 }, UNIQUE),
index({ songID: 1, difficulty: 1, playtype: 1, isPrimary: 1 }, UNIQUE),
];
}
if (indexes[`charts-${game}` as ValidDatabases]) {
indexes[`charts-${game}` as ValidDatabases]!.push(
index({ chartID: 1 }, UNIQUE),
index(
{ songID: 1, difficulty: 1, playtype: 1, isPrimary: 1 },
{ unique: true, partialFilterExpression: { isPrimary: { $eq: true } } }
)
);
} else {
indexes[`charts-${game}` as ValidDatabases] = [
index({ chartID: 1 }, UNIQUE),
index({ songID: 1, difficulty: 1, playtype: 1, isPrimary: 1 }, UNIQUE),
];
}
if (indexes[`songs-${game}` as ValidDatabases]) {
indexes[`songs-${game}` as ValidDatabases]!.push(
index({ id: 1 }, UNIQUE),
index({ title: "text", artist: "text", "alt-titles": "text", "search-titles": "text" })
);
} else {
indexes[`songs-${game}` as ValidDatabases] = [
index({ id: 1 }, UNIQUE),
index({ title: 1 }),
index({ title: "text", artist: "text", "alt-titles": "text", "search-titles": "text" }),
];
}
if (indexes[`songs-${game}` as ValidDatabases]) {
indexes[`songs-${game}` as ValidDatabases]!.push(
index({ id: 1 }, UNIQUE),
index({ title: "text", artist: "text", "alt-titles": "text", "search-titles": "text" })
);
} else {
indexes[`songs-${game}` as ValidDatabases] = [
index({ id: 1 }, UNIQUE),
index({ title: 1 }),
index({ title: "text", artist: "text", "alt-titles": "text", "search-titles": "text" }),
];
}
}
export async function SetIndexes(mongoURL: string, reset: boolean) {
const db = monk(mongoURL);
const db = monk(mongoURL);
logger.debug(`Starting indexing for ${mongoURL}...`);
logger.debug(`Starting indexing for ${mongoURL}...`);
const collections = (await db.listCollections()).map((e) => e.name);
const collections = (await db.listCollections()).map((e) => e.name);
for (const collection in indexes) {
if (!collections.includes(collection)) {
// this creates a collection, i cant find the createCollection
// call.
await db.get(collection).insert({});
await db.get(collection).remove({});
}
for (const collection in indexes) {
if (!collections.includes(collection)) {
// this creates a collection, i cant find the createCollection
// call.
await db.get(collection).insert({});
await db.get(collection).remove({});
}
if (reset) {
await db.get(collection).dropIndexes();
logger.debug(`Reset ${collection}.`);
}
if (reset) {
await db.get(collection).dropIndexes();
logger.debug(`Reset ${collection}.`);
}
// @ts-expect-error dru(n)kts
for (const index of indexes[collection]) {
const r = await db.get(collection).createIndex(index.fields, index.options);
// @ts-expect-error dru(n)kts
for (const index of indexes[collection]) {
const r = await db.get(collection).createIndex(index.fields, index.options);
logger.debug(r);
}
}
logger.debug(r);
}
}
logger.debug("Done.");
logger.debug("Done.");
await db.close();
await db.close();
}
+352 -352
View File
@@ -1,12 +1,12 @@
import deepmerge from "deepmerge";
import { Game, Playtypes } from "tachi-common";
import {
grades,
importTypes,
lamps,
validHitData,
validDifficulties,
validPlaytypes,
grades,
importTypes,
lamps,
validHitData,
validDifficulties,
validPlaytypes,
} from "tachi-common/js/config";
import p, { PrudenceSchema, ValidSchemaValue } from "prudence";
import { CONF_INFO } from "../../lib/setup/config";
@@ -14,99 +14,99 @@ import { CONF_INFO } from "../../lib/setup/config";
const LAZY_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u;
export const PRUDENCE_PUBLIC_USER: PrudenceSchema = {
_id: p.any,
username: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u),
usernameLowercase: (self, parent) => self === (parent!.username as string).toLowerCase(),
id: p.isPositiveInteger,
settings: {
nsfwSplashes: "boolean",
invisible: "boolean",
},
friends: [p.isPositiveInteger],
socialMedia: {
discord: "*?string",
twitter: "*?string",
github: "*?string",
steam: "*?string",
youtube: "*?string",
twitch: "*?string",
},
about: p.isBoundedString(0, 4000),
customPfp: "boolean",
customBanner: "boolean",
permissions: {
admin: "*boolean",
},
clan: p.nullable(p.isBoundedString(1, 4)),
lastSeen: p.nullable(p.isPositiveInteger),
_id: p.any,
username: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u),
usernameLowercase: (self, parent) => self === (parent!.username as string).toLowerCase(),
id: p.isPositiveInteger,
settings: {
nsfwSplashes: "boolean",
invisible: "boolean",
},
friends: [p.isPositiveInteger],
socialMedia: {
discord: "*?string",
twitter: "*?string",
github: "*?string",
steam: "*?string",
youtube: "*?string",
twitch: "*?string",
},
about: p.isBoundedString(0, 4000),
customPfp: "boolean",
customBanner: "boolean",
permissions: {
admin: "*boolean",
},
clan: p.nullable(p.isBoundedString(1, 4)),
lastSeen: p.nullable(p.isPositiveInteger),
};
export const PRUDENCE_PRIVATE_USER = Object.assign(
{
password: "string", // could be a tighter fit related to bcrypt?
email: p.regex(LAZY_EMAIL_REGEX),
},
PRUDENCE_PUBLIC_USER
{
password: "string", // could be a tighter fit related to bcrypt?
email: p.regex(LAZY_EMAIL_REGEX),
},
PRUDENCE_PUBLIC_USER
);
export const PRUDENCE_IIDX_BPI_DATA = {
coef: p.nullable(p.isPositiveNonZero),
kavg: p.isPositiveNonZeroInteger,
wr: p.isPositiveNonZeroInteger,
chartID: "string",
kesd: p.isPositiveNonZero,
coef: p.nullable(p.isPositiveNonZero),
kavg: p.isPositiveNonZeroInteger,
wr: p.isPositiveNonZeroInteger,
chartID: "string",
kesd: p.isPositiveNonZero,
};
export const PRUDENCE_COUNTER = {
counterName: "string",
value: p.isPositiveNonZeroInteger, // is nonzero?
counterName: "string",
value: p.isPositiveNonZeroInteger, // is nonzero?
};
export const PR_SCORE_GENERIC = {
service: p.isBoundedString(3, 64),
game: "string",
playtype: "string",
difficulty: "string",
userID: p.isPositiveNonZeroInteger,
scoreData: {
score: p.isPositive,
percent: p.isBetween(0, 100), // could be overrode!
lamp: "string",
grade: "string",
lampIndex: p.isPositiveInteger,
gradeIndex: p.isPositiveInteger,
hitData: {},
hitMeta: {},
},
scoreMeta: {},
calculatedData: {
rating: p.isPositive,
lampRating: p.isPositive,
gameSpecific: {},
// ranking: p.nullable(p.isPositiveNonZeroInteger),
// outOf: p.nullable(
// p.and(
// p.isPositiveNonZeroInteger,
// (self, parent: Record<string, unknown>) =>
// (parent.ranking as number) <= (self as number)
// )
// ),
},
timeAchieved: p.nullable(p.isPositive),
songID: p.isInteger,
chartID: (self: unknown) => typeof self === "string" && self.length === 40,
highlight: "boolean",
isPrimary: "boolean",
comment: p.nullable(p.isBoundedString(1, 240)),
timeAdded: p.isPositive,
scoreID: "string", // temp
importType: p.nullable(p.isIn(importTypes)),
service: p.isBoundedString(3, 64),
game: "string",
playtype: "string",
difficulty: "string",
userID: p.isPositiveNonZeroInteger,
scoreData: {
score: p.isPositive,
percent: p.isBetween(0, 100), // could be overrode!
lamp: "string",
grade: "string",
lampIndex: p.isPositiveInteger,
gradeIndex: p.isPositiveInteger,
hitData: {},
hitMeta: {},
},
scoreMeta: {},
calculatedData: {
rating: p.isPositive,
lampRating: p.isPositive,
gameSpecific: {},
// ranking: p.nullable(p.isPositiveNonZeroInteger),
// outOf: p.nullable(
// p.and(
// p.isPositiveNonZeroInteger,
// (self, parent: Record<string, unknown>) =>
// (parent.ranking as number) <= (self as number)
// )
// ),
},
timeAchieved: p.nullable(p.isPositive),
songID: p.isInteger,
chartID: (self: unknown) => typeof self === "string" && self.length === 40,
highlight: "boolean",
isPrimary: "boolean",
comment: p.nullable(p.isBoundedString(1, 240)),
timeAdded: p.isPositive,
scoreID: "string", // temp
importType: p.nullable(p.isIn(importTypes)),
};
type ScoreSchemas = {
[G in Game]: {
[P in Playtypes[G]]: PrudenceSchema;
};
[G in Game]: {
[P in Playtypes[G]]: PrudenceSchema;
};
};
const optionalPositiveInt = p.optional(p.isPositiveInteger);
@@ -116,315 +116,315 @@ const nullableAndOptional = (fn: ValidSchemaValue) => p.optional(p.nullable(fn))
const nullableAndOptionalPosInt = nullableAndOptional(p.isPositiveInteger);
const CreateGameScoreData = (game: Game, hitMetaMerge: PrudenceSchema) => ({
lamp: p.isIn(lamps[game]),
lampIndex: p.and(
p.isPositiveInteger,
(self, parent) => lamps[game][self as number] === parent.lamp
),
grade: p.isIn(grades[game]),
gradeIndex: p.and(
p.isPositiveInteger,
(self, parent) => grades[game][self as number] === parent.grade
),
hitData: Object.fromEntries(validHitData[game].map((e) => [e, optionalPositiveInt])),
hitMeta: deepmerge(
{
fast: nullableAndOptionalPosInt,
slow: nullableAndOptionalPosInt,
maxCombo: nullableAndOptionalPosInt,
},
hitMetaMerge
),
esd: p.nullable(p.isPositive),
lamp: p.isIn(lamps[game]),
lampIndex: p.and(
p.isPositiveInteger,
(self, parent) => lamps[game][self as number] === parent.lamp
),
grade: p.isIn(grades[game]),
gradeIndex: p.and(
p.isPositiveInteger,
(self, parent) => grades[game][self as number] === parent.grade
),
hitData: Object.fromEntries(validHitData[game].map((e) => [e, optionalPositiveInt])),
hitMeta: deepmerge(
{
fast: nullableAndOptionalPosInt,
slow: nullableAndOptionalPosInt,
maxCombo: nullableAndOptionalPosInt,
},
hitMetaMerge
),
esd: p.nullable(p.isPositive),
});
const PR_SCORE_IIDX_SP: PrudenceSchema = CreatePRScore(
"iidx",
"SP",
{
bp: nullableAndOptional(p.isPositiveInteger),
gauge: nullableAndOptional(p.isBetween(0, 100)),
gaugeHistory: p.optional([nullableAndOptional(p.isBetween(0, 100))]),
comboBreak: optionalPositiveInt,
deadMeasure: nullableAndOptional(p.isPositiveInteger),
deadNote: nullableAndOptional(p.isPositiveInteger),
},
{
random: nullableAndOptional(p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])),
gauge: nullableAndOptional(p.isIn(["ASSISTED EASY", "EASY", "NORMAL", "HARD", "EX-HARD"])),
assist: nullableAndOptional(
p.isIn(["NO ASSIST", "AUTO SCRATCH", "LEGACY NOTE", "ASCR + LEGACY"])
),
range: nullableAndOptional(
p.isIn(["NONE", "SUDDEN+", "HIDDEN+", "SUD+ HID+", "LIFT", "LIFT SUD+"])
),
pacemaker: "*?string", // lazy
pacemakerName: "*?string",
pacemakerTarget: nullableAndOptional(p.isPositiveInteger),
},
{
BPI: "?number",
"K%": p.nullable(p.isBetween(0, 100)),
}
"iidx",
"SP",
{
bp: nullableAndOptional(p.isPositiveInteger),
gauge: nullableAndOptional(p.isBetween(0, 100)),
gaugeHistory: p.optional([nullableAndOptional(p.isBetween(0, 100))]),
comboBreak: optionalPositiveInt,
deadMeasure: nullableAndOptional(p.isPositiveInteger),
deadNote: nullableAndOptional(p.isPositiveInteger),
},
{
random: nullableAndOptional(p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])),
gauge: nullableAndOptional(p.isIn(["ASSISTED EASY", "EASY", "NORMAL", "HARD", "EX-HARD"])),
assist: nullableAndOptional(
p.isIn(["NO ASSIST", "AUTO SCRATCH", "LEGACY NOTE", "ASCR + LEGACY"])
),
range: nullableAndOptional(
p.isIn(["NONE", "SUDDEN+", "HIDDEN+", "SUD+ HID+", "LIFT", "LIFT SUD+"])
),
pacemaker: "*?string", // lazy
pacemakerName: "*?string",
pacemakerTarget: nullableAndOptional(p.isPositiveInteger),
},
{
BPI: "?number",
"K%": p.nullable(p.isBetween(0, 100)),
}
);
const PR_SCORE_BMS_7K: PrudenceSchema = CreatePRScore(
"bms",
"7K",
{
gauge: nullableAndOptional(p.isBetween(0, 100)),
bp: nullableAndOptionalPosInt,
diedAt: nullableAndOptionalPosInt,
lpr: nullableAndOptionalPosInt,
lbd: nullableAndOptionalPosInt,
lgd: nullableAndOptionalPosInt,
lgr: nullableAndOptionalPosInt,
lpg: nullableAndOptionalPosInt,
epr: nullableAndOptionalPosInt,
ebd: nullableAndOptionalPosInt,
egd: nullableAndOptionalPosInt,
egr: nullableAndOptionalPosInt,
epg: nullableAndOptionalPosInt,
},
{
random: nullableAndOptional(p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])),
inputDevice: nullableAndOptional(p.isIn(["KEYBOARD", "BM_CONTROLLER", "MIDI"])),
}
"bms",
"7K",
{
gauge: nullableAndOptional(p.isBetween(0, 100)),
bp: nullableAndOptionalPosInt,
diedAt: nullableAndOptionalPosInt,
lpr: nullableAndOptionalPosInt,
lbd: nullableAndOptionalPosInt,
lgd: nullableAndOptionalPosInt,
lgr: nullableAndOptionalPosInt,
lpg: nullableAndOptionalPosInt,
epr: nullableAndOptionalPosInt,
ebd: nullableAndOptionalPosInt,
egd: nullableAndOptionalPosInt,
egr: nullableAndOptionalPosInt,
epg: nullableAndOptionalPosInt,
},
{
random: nullableAndOptional(p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])),
inputDevice: nullableAndOptional(p.isIn(["KEYBOARD", "BM_CONTROLLER", "MIDI"])),
}
);
function CreatePRScore<G extends Game>(
game: G,
playtype: Playtypes[G],
mergeHitMeta: PrudenceSchema = {},
mergeScoreMeta: PrudenceSchema = {},
mergeGameSpecific: PrudenceSchema = {}
game: G,
playtype: Playtypes[G],
mergeHitMeta: PrudenceSchema = {},
mergeScoreMeta: PrudenceSchema = {},
mergeGameSpecific: PrudenceSchema = {}
) {
return deepmerge(PR_SCORE_GENERIC, {
game: p.equalTo(game),
playtype: p.equalTo(playtype),
scoreData: CreateGameScoreData(game, mergeHitMeta),
scoreMeta: mergeScoreMeta,
calculatedData: {
gameSpecific: mergeGameSpecific,
},
});
return deepmerge(PR_SCORE_GENERIC, {
game: p.equalTo(game),
playtype: p.equalTo(playtype),
scoreData: CreateGameScoreData(game, mergeHitMeta),
scoreMeta: mergeScoreMeta,
calculatedData: {
gameSpecific: mergeGameSpecific,
},
});
}
function DoublePlayTwinRandomTuple(self: unknown) {
if (!Array.isArray(self)) {
return "Expected an array.";
}
if (!Array.isArray(self)) {
return "Expected an array.";
}
if (self.length !== 2) {
return "Expected exactly 2 elements.";
}
if (self.length !== 2) {
return "Expected exactly 2 elements.";
}
const ls = p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])(self[0]);
const ls = p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])(self[0]);
if (ls !== true) {
return ls;
}
if (ls !== true) {
return ls;
}
const rs = p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])(self[1]);
const rs = p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])(self[1]);
return rs;
return rs;
}
export const PRUDENCE_SCORE_SCHEMAS: ScoreSchemas = {
iidx: {
SP: PR_SCORE_IIDX_SP,
DP: deepmerge(PR_SCORE_IIDX_SP, {
playtype: p.equalTo("DP"),
scoreMeta: {
random: nullableAndOptional(DoublePlayTwinRandomTuple),
},
calculatedData: {
gameSpecific: {
"K%": "undefined", // remove with override
},
},
}),
},
sdvx: {
Single: CreatePRScore(
"sdvx",
"Single",
{
gauge: p.optional(p.isBetween(0, 100)),
btnRate: p.optional(p.isBetween(0, 100)),
holdRate: p.optional(p.isBetween(0, 100)),
laserRate: p.optional(p.isBetween(0, 100)),
},
{},
{
VF4: p.nullable(p.isPositiveInteger),
VF5: p.nullable(p.isBetween(0, 0.5)), // hack
}
),
},
bms: {
"7K": PR_SCORE_BMS_7K,
"14K": deepmerge(PR_SCORE_BMS_7K, {
playtype: p.equalTo("14K"),
scoreMeta: {
random: p.optional(DoublePlayTwinRandomTuple),
},
}),
},
chunithm: {
Single: deepmerge(CreatePRScore("chunithm", "Single"), {
scoreData: { percent: p.isBetween(0, 101) },
}),
},
ddr: {
SP: CreatePRScore(
"ddr",
"SP",
{},
{},
{
MFCP: p.nullable(p.isBoundedInteger(1, 25)),
}
),
DP: CreatePRScore(
"ddr",
"DP",
{},
{},
{
MFCP: p.nullable(p.isBoundedInteger(1, 25)),
}
),
},
gitadora: {
Gita: CreatePRScore("gitadora", "Gita"),
Dora: CreatePRScore("gitadora", "Dora"),
},
jubeat: {
Single: CreatePRScore("jubeat", "Single"),
},
maimai: {
Single: deepmerge(CreatePRScore("maimai", "Single"), {
scoreData: { percent: p.isBetween(0, 150) },
}),
},
museca: {
Single: CreatePRScore("museca", "Single"),
},
popn: {
"9B": CreatePRScore("popn", "9B", { gauge: p.optional(p.isBetween(0, 100)) }),
},
usc: {
Single: CreatePRScore("usc", "Single", { gauge: p.optional(p.isBetween(0, 100)) }),
},
iidx: {
SP: PR_SCORE_IIDX_SP,
DP: deepmerge(PR_SCORE_IIDX_SP, {
playtype: p.equalTo("DP"),
scoreMeta: {
random: nullableAndOptional(DoublePlayTwinRandomTuple),
},
calculatedData: {
gameSpecific: {
"K%": "undefined", // remove with override
},
},
}),
},
sdvx: {
Single: CreatePRScore(
"sdvx",
"Single",
{
gauge: p.optional(p.isBetween(0, 100)),
btnRate: p.optional(p.isBetween(0, 100)),
holdRate: p.optional(p.isBetween(0, 100)),
laserRate: p.optional(p.isBetween(0, 100)),
},
{},
{
VF4: p.nullable(p.isPositiveInteger),
VF5: p.nullable(p.isBetween(0, 0.5)), // hack
}
),
},
bms: {
"7K": PR_SCORE_BMS_7K,
"14K": deepmerge(PR_SCORE_BMS_7K, {
playtype: p.equalTo("14K"),
scoreMeta: {
random: p.optional(DoublePlayTwinRandomTuple),
},
}),
},
chunithm: {
Single: deepmerge(CreatePRScore("chunithm", "Single"), {
scoreData: { percent: p.isBetween(0, 101) },
}),
},
ddr: {
SP: CreatePRScore(
"ddr",
"SP",
{},
{},
{
MFCP: p.nullable(p.isBoundedInteger(1, 25)),
}
),
DP: CreatePRScore(
"ddr",
"DP",
{},
{},
{
MFCP: p.nullable(p.isBoundedInteger(1, 25)),
}
),
},
gitadora: {
Gita: CreatePRScore("gitadora", "Gita"),
Dora: CreatePRScore("gitadora", "Dora"),
},
jubeat: {
Single: CreatePRScore("jubeat", "Single"),
},
maimai: {
Single: deepmerge(CreatePRScore("maimai", "Single"), {
scoreData: { percent: p.isBetween(0, 150) },
}),
},
museca: {
Single: CreatePRScore("museca", "Single"),
},
popn: {
"9B": CreatePRScore("popn", "9B", { gauge: p.optional(p.isBetween(0, 100)) }),
},
usc: {
Single: CreatePRScore("usc", "Single", { gauge: p.optional(p.isBetween(0, 100)) }),
},
};
const PRUDENCE_CHART_BASE: PrudenceSchema = {
chartID: "string",
rgcID: "?string",
songID: p.isPositiveInteger,
level: "string",
levelNum: p.isPositive,
isPrimary: "boolean",
versions: ["string"],
chartID: "string",
rgcID: "?string",
songID: p.isPositiveInteger,
level: "string",
levelNum: p.isPositive,
isPrimary: "boolean",
versions: ["string"],
};
function CreatePrChart(game: Game, flags: string[], data: PrudenceSchema) {
return deepmerge(PRUDENCE_CHART_BASE, {
difficulty: p.isIn(validDifficulties[game]),
playtype: p.isIn(validPlaytypes[game]),
flags: Object.fromEntries(flags.map((e) => [e, "boolean"])),
data,
});
return deepmerge(PRUDENCE_CHART_BASE, {
difficulty: p.isIn(validDifficulties[game]),
playtype: p.isIn(validPlaytypes[game]),
flags: Object.fromEntries(flags.map((e) => [e, "boolean"])),
data,
});
}
export const PRUDENCE_CHART_SCHEMAS: Record<Game, PrudenceSchema> = {
iidx: CreatePrChart("iidx", ["IN BASE GAME", "OMNIMIX", "N-1"], {
notecount: p.isPositiveInteger,
inGameID: p.isPositiveInteger,
arcChartID: "?string",
}),
bms: CreatePrChart("bms", [], { hashMD5: "string", hashSHA256: "string" }),
chunithm: CreatePrChart("chunithm", ["IN BASE GAME", "OMNIMIX"], {
inGameID: p.isPositiveInteger,
}),
ddr: CreatePrChart("ddr", ["IN BASE GAME", "N-1"], {
songHash: "string",
inGameID: "string",
}),
gitadora: CreatePrChart("gitadora", ["IN BASE GAME", "OMNIMIX", "HOT N-1", "HOT"], {
inGameID: p.isPositiveInteger,
}),
jubeat: CreatePrChart("jubeat", ["IN BASE GAME"], {}),
maimai: CreatePrChart("maimai", ["IN BASE GAME"], {
maxPercent: p.gte(100),
inGameID: "string",
}),
museca: CreatePrChart("museca", ["IN BASE GAME", "OMNIMIX"], {}),
popn: CreatePrChart("popn", ["IN BASE GAME", "OMNIMIX"], {}),
sdvx: CreatePrChart("sdvx", ["IN BASE GAME", "OMNIMIX", "N-1"], {
inGameID: p.isPositiveInteger,
}),
usc: CreatePrChart("usc", ["CUSTOM"], {
hashSHA256: "string",
}),
iidx: CreatePrChart("iidx", ["IN BASE GAME", "OMNIMIX", "N-1"], {
notecount: p.isPositiveInteger,
inGameID: p.isPositiveInteger,
arcChartID: "?string",
}),
bms: CreatePrChart("bms", [], { hashMD5: "string", hashSHA256: "string" }),
chunithm: CreatePrChart("chunithm", ["IN BASE GAME", "OMNIMIX"], {
inGameID: p.isPositiveInteger,
}),
ddr: CreatePrChart("ddr", ["IN BASE GAME", "N-1"], {
songHash: "string",
inGameID: "string",
}),
gitadora: CreatePrChart("gitadora", ["IN BASE GAME", "OMNIMIX", "HOT N-1", "HOT"], {
inGameID: p.isPositiveInteger,
}),
jubeat: CreatePrChart("jubeat", ["IN BASE GAME"], {}),
maimai: CreatePrChart("maimai", ["IN BASE GAME"], {
maxPercent: p.gte(100),
inGameID: "string",
}),
museca: CreatePrChart("museca", ["IN BASE GAME", "OMNIMIX"], {}),
popn: CreatePrChart("popn", ["IN BASE GAME", "OMNIMIX"], {}),
sdvx: CreatePrChart("sdvx", ["IN BASE GAME", "OMNIMIX", "N-1"], {
inGameID: p.isPositiveInteger,
}),
usc: CreatePrChart("usc", ["CUSTOM"], {
hashSHA256: "string",
}),
};
const PRUDENCE_SONG_BASE: PrudenceSchema = {
id: p.isPositiveInteger,
title: "string",
artist: "string",
"search-titles": [p.and("string", (self, parent) => self !== parent.title)],
"alt-titles": [p.and("string", (self, parent) => self !== parent.title)],
firstVersion: "string",
id: p.isPositiveInteger,
title: "string",
artist: "string",
"search-titles": [p.and("string", (self, parent) => self !== parent.title)],
"alt-titles": [p.and("string", (self, parent) => self !== parent.title)],
firstVersion: "string",
};
function CreatePrSong(data: PrudenceSchema) {
return deepmerge(PRUDENCE_SONG_BASE, {
data,
});
return deepmerge(PRUDENCE_SONG_BASE, {
data,
});
}
const PRUDENCE_SONG_WITH_GENRE = CreatePrSong({ genre: "string" });
export const PRUDENCE_SONG_SCHEMAS: Record<Game, PrudenceSchema> = {
iidx: PRUDENCE_SONG_WITH_GENRE,
bms: PRUDENCE_SONG_WITH_GENRE,
chunithm: PRUDENCE_SONG_WITH_GENRE,
ddr: CreatePrSong({}),
gitadora: CreatePrSong({}),
jubeat: CreatePrSong({}),
maimai: CreatePrSong({ titleJP: "string", artistJP: "string", genre: "string" }),
museca: CreatePrSong({ titleJP: "string", artistJP: "string" }),
popn: PRUDENCE_SONG_WITH_GENRE,
sdvx: CreatePrSong({ uscEquiv: p.nullable(p.isPositiveInteger) }),
usc: CreatePrSong({ sdvxEquiv: p.nullable(p.isPositiveInteger) }),
iidx: PRUDENCE_SONG_WITH_GENRE,
bms: PRUDENCE_SONG_WITH_GENRE,
chunithm: PRUDENCE_SONG_WITH_GENRE,
ddr: CreatePrSong({}),
gitadora: CreatePrSong({}),
jubeat: CreatePrSong({}),
maimai: CreatePrSong({ titleJP: "string", artistJP: "string", genre: "string" }),
museca: CreatePrSong({ titleJP: "string", artistJP: "string" }),
popn: PRUDENCE_SONG_WITH_GENRE,
sdvx: CreatePrSong({ uscEquiv: p.nullable(p.isPositiveInteger) }),
usc: CreatePrSong({ sdvxEquiv: p.nullable(p.isPositiveInteger) }),
};
const PRUDENCE_FOLDER_CHART_LOOKUP: PrudenceSchema = {
chartID: "string",
folderID: "string",
chartID: "string",
folderID: "string",
};
const PRUDENCE_FOLDER: PrudenceSchema = {
title: "string",
game: p.isIn(CONF_INFO.SUPPORTED_GAMES),
playtype: (self, parent) => p.isIn(validPlaytypes[parent.game as Game])(self),
folderID: "string",
table: "string",
tableIndex: "number",
type: p.isIn("songs", "charts", "static"),
data: (self, parent) => {
if (parent.type === "static") {
return Array.isArray(self) && self.every((e) => typeof e === "string");
}
title: "string",
game: p.isIn(CONF_INFO.SUPPORTED_GAMES),
playtype: (self, parent) => p.isIn(validPlaytypes[parent.game as Game])(self),
folderID: "string",
table: "string",
tableIndex: "number",
type: p.isIn("songs", "charts", "static"),
data: (self, parent) => {
if (parent.type === "static") {
return Array.isArray(self) && self.every((e) => typeof e === "string");
}
// this is a temp hackjob, it should technically
// check whether this document matches a
// partialised chart/song doc
// but.. can be done later.
return typeof self === "object" && !!self; // whatever
},
// this is a temp hackjob, it should technically
// check whether this document matches a
// partialised chart/song doc
// but.. can be done later.
return typeof self === "object" && !!self; // whatever
},
};
/**
@@ -433,11 +433,11 @@ const PRUDENCE_FOLDER: PrudenceSchema = {
* being different depending on the score.game field.
*/
export const STATIC_SCHEMAS = {
users: PRUDENCE_PRIVATE_USER,
"iidx-bpi-data": PRUDENCE_IIDX_BPI_DATA,
counters: PRUDENCE_COUNTER,
charts: PRUDENCE_CHART_SCHEMAS,
songs: PRUDENCE_SONG_SCHEMAS,
"folder-chart-lookup": PRUDENCE_FOLDER_CHART_LOOKUP,
folders: PRUDENCE_FOLDER,
users: PRUDENCE_PRIVATE_USER,
"iidx-bpi-data": PRUDENCE_IIDX_BPI_DATA,
counters: PRUDENCE_COUNTER,
charts: PRUDENCE_CHART_SCHEMAS,
songs: PRUDENCE_SONG_SCHEMAS,
"folder-chart-lookup": PRUDENCE_FOLDER_CHART_LOOKUP,
folders: PRUDENCE_FOLDER,
};
+7 -7
View File
@@ -3,14 +3,14 @@ import t from "tap";
import { CloseRedisPubSub, RedisPub, RedisSub } from "./redis-IPC";
t.test("Basic PUB/SUB testing", (t) => {
t.setTimeout(2000);
RedisSub("test" as any, (d) => {
t.equal(d.userID, 1);
t.pass();
t.end();
});
t.setTimeout(2000);
RedisSub("test" as any, (d) => {
t.equal(d.userID, 1);
t.pass();
t.end();
});
RedisPub("test" as any, { userID: 1 } as any);
RedisPub("test" as any, { userID: 1 } as any);
});
t.teardown(CloseRedisPubSub);
+43 -43
View File
@@ -8,7 +8,7 @@ const logger = CreateLogCtx(__filename);
type RedisSubCallback<T extends RedisIPCChannels> = (data: RedisIPCData[T]) => void;
type SubCallbacks = {
[K in RedisIPCChannels]: RedisSubCallback<K>[];
[K in RedisIPCChannels]: RedisSubCallback<K>[];
};
const SubCallbacks: Partial<SubCallbacks> = {};
@@ -22,64 +22,64 @@ const PubClient = redis.createClient();
const PREFIX = CONFIG.TYPE.toUpperCase(); // KTCHI or BTCHI
export function RedisPub<T extends RedisIPCChannels>(channel: T, data: RedisIPCData[T]) {
PubClient.publish(`${PREFIX}-${channel}`, JSON.stringify(data));
PubClient.publish(`${PREFIX}-${channel}`, JSON.stringify(data));
}
export function RedisSub<T extends RedisIPCChannels>(channel: T, callback: RedisSubCallback<T>) {
if (SubCallbacks[channel]) {
// @ts-expect-error It's complaining that the T in channel might be a different T to the T in callback
// this is obviously nonsense.
SubCallbacks[channel]!.push(callback);
logger.debug(`Pushed callback ${callback.name} to channel ${channel}.`);
} else {
// @ts-expect-error see above.
SubCallbacks[channel] = [callback];
SubClient.subscribe(`${PREFIX}-${channel}`);
logger.debug(`Added first callback ${callback.name} to channel ${channel}.`);
}
if (SubCallbacks[channel]) {
// @ts-expect-error It's complaining that the T in channel might be a different T to the T in callback
// this is obviously nonsense.
SubCallbacks[channel]!.push(callback);
logger.debug(`Pushed callback ${callback.name} to channel ${channel}.`);
} else {
// @ts-expect-error see above.
SubCallbacks[channel] = [callback];
SubClient.subscribe(`${PREFIX}-${channel}`);
logger.debug(`Added first callback ${callback.name} to channel ${channel}.`);
}
}
SubClient.on("message", (channel, strData) => {
if (!channel.startsWith(`${PREFIX}-`)) {
return; // not our business
}
if (!channel.startsWith(`${PREFIX}-`)) {
return; // not our business
}
const ktChannel = channel.slice(`${PREFIX}-`.length) as RedisIPCChannels;
const ktChannel = channel.slice(`${PREFIX}-`.length) as RedisIPCChannels;
if (!Object.prototype.hasOwnProperty.call(SubCallbacks, ktChannel)) {
return; // no callbacks to call
}
if (!Object.prototype.hasOwnProperty.call(SubCallbacks, ktChannel)) {
return; // no callbacks to call
}
const jsData = JSON.parse(strData);
const jsData = JSON.parse(strData);
for (const cb of SubCallbacks[ktChannel]!) {
try {
cb(jsData);
} catch (err) {
logger.error(`Error calling callback ${cb.name} for channel ${ktChannel}`, { err });
}
}
for (const cb of SubCallbacks[ktChannel]!) {
try {
cb(jsData);
} catch (err) {
logger.error(`Error calling callback ${cb.name} for channel ${ktChannel}`, { err });
}
}
});
// Awful...
// Function is near-impossible to test.
/* istanbul ignore next */
export function CloseRedisPubSub() {
return new Promise<void>((resolve, reject) => {
PubClient.quit((err) => {
if (err) {
logger.crit(`PubClient QUIT error: ${err}`, { err });
reject(err);
}
return new Promise<void>((resolve, reject) => {
PubClient.quit((err) => {
if (err) {
logger.crit(`PubClient QUIT error: ${err}`, { err });
reject(err);
}
SubClient.quit((err) => {
if (err) {
logger.crit(`SubClient QUIT error: ${err}`, { err });
reject(err);
}
SubClient.quit((err) => {
if (err) {
logger.crit(`SubClient QUIT error: ${err}`, { err });
reject(err);
}
resolve();
});
});
});
resolve();
});
});
});
}
+7 -7
View File
@@ -9,20 +9,20 @@ export const RedisClient = redis.createClient();
logger.verbose("Instantiated Redis Client");
function EmitCritical() {
/* istanbul ignore next */
if (!RedisClient.connected) {
logger.crit(`Could not connect to redis in time. No more information is available.`);
}
/* istanbul ignore next */
if (!RedisClient.connected) {
logger.crit(`Could not connect to redis in time. No more information is available.`);
}
}
const ref = setTimeout(EmitCritical, 10000);
RedisClient.on("connect", () => {
logger.verbose(`Connected to Redis.`);
logger.verbose(`Connected to Redis.`);
clearTimeout(ref);
clearTimeout(ref);
});
export function CloseRedisConnection() {
return RedisClient.quit();
return RedisClient.quit();
}
+239 -239
View File
@@ -6,10 +6,10 @@ import { EvaluateGoalForUser, HumaniseGoalProgress } from "./goals";
import { GoalDocument } from "tachi-common";
import deepmerge from "deepmerge";
import {
HC511Goal,
Testing511SPA,
TestingIIDXFolderSP10,
TestingIIDXSPScorePB,
HC511Goal,
Testing511SPA,
TestingIIDXFolderSP10,
TestingIIDXSPScorePB,
} from "../../test-utils/test-data";
import { CreateFolderChartLookup } from "../../utils/folder";
import { CloseAllConnections } from "../../test-utils/close-connections";
@@ -17,292 +17,292 @@ import { CloseAllConnections } from "../../test-utils/close-connections";
const logger = CreateLogCtx(__filename);
t.test("#EvaluateGoalForUser", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should correctly evaluate against single goals.", (t) => {
t.beforeEach(async () => {
await db["personal-bests"].insert(TestingIIDXSPScorePB);
delete TestingIIDXSPScorePB._id;
});
t.test("Should correctly evaluate against single goals.", (t) => {
t.beforeEach(async () => {
await db["personal-bests"].insert(TestingIIDXSPScorePB);
delete TestingIIDXSPScorePB._id;
});
t.test("Should correctly evaluate goals if user succeeds.", async (t) => {
const res = await EvaluateGoalForUser(HC511Goal, 1, logger);
t.test("Should correctly evaluate goals if user succeeds.", async (t) => {
const res = await EvaluateGoalForUser(HC511Goal, 1, logger);
t.strictSame(
res,
{
achieved: true,
outOf: 5,
progress: 6,
outOfHuman: "HARD CLEAR",
progressHuman: "EX HARD CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user succeeds."
);
t.strictSame(
res,
{
achieved: true,
outOf: 5,
progress: 6,
outOfHuman: "HARD CLEAR",
progressHuman: "EX HARD CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user succeeds."
);
t.end();
});
t.end();
});
t.test("Should correctly evaluate goals if user fails.", async (t) => {
await db["personal-bests"].update(
{ userID: 1, chartID: Testing511SPA.chartID },
{
$set: {
"scoreData.lamp": "CLEAR",
"scoreData.lampIndex": 4,
},
}
);
t.test("Should correctly evaluate goals if user fails.", async (t) => {
await db["personal-bests"].update(
{ userID: 1, chartID: Testing511SPA.chartID },
{
$set: {
"scoreData.lamp": "CLEAR",
"scoreData.lampIndex": 4,
},
}
);
const res = await EvaluateGoalForUser(HC511Goal, 1, logger);
const res = await EvaluateGoalForUser(HC511Goal, 1, logger);
t.strictSame(
res,
{
achieved: false,
outOf: 5,
progress: 4,
outOfHuman: "HARD CLEAR",
progressHuman: "CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user fails."
);
t.strictSame(
res,
{
achieved: false,
outOf: 5,
progress: 4,
outOfHuman: "HARD CLEAR",
progressHuman: "CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user fails."
);
t.end();
});
t.end();
});
t.test("Should correctly evaluate goals when user has no score.", async (t) => {
await db["personal-bests"].remove({});
t.test("Should correctly evaluate goals when user has no score.", async (t) => {
await db["personal-bests"].remove({});
const res = await EvaluateGoalForUser(HC511Goal, 1, logger);
const res = await EvaluateGoalForUser(HC511Goal, 1, logger);
t.strictSame(
res,
{
achieved: false,
outOf: 5,
progress: null,
outOfHuman: "HARD CLEAR",
progressHuman: "NO DATA",
},
"Should correctly evaluate the goal if the user has no score."
);
t.strictSame(
res,
{
achieved: false,
outOf: 5,
progress: null,
outOfHuman: "HARD CLEAR",
progressHuman: "NO DATA",
},
"Should correctly evaluate the goal if the user has no score."
);
t.end();
});
t.end();
});
t.end();
});
t.end();
});
t.test("Should correctly evaluate against multi goals.", (t) => {
// @ts-expect-error ???
// this goal is effectively "HC any of the charts"
const multiGoal: GoalDocument = deepmerge(HC511Goal, {
charts: {
type: "multi",
data: [Testing511SPA.chartID, "fake_other_chart_id"],
},
});
t.test("Should correctly evaluate against multi goals.", (t) => {
// @ts-expect-error ???
// this goal is effectively "HC any of the charts"
const multiGoal: GoalDocument = deepmerge(HC511Goal, {
charts: {
type: "multi",
data: [Testing511SPA.chartID, "fake_other_chart_id"],
},
});
t.beforeEach(async () => {
await db["personal-bests"].insert(TestingIIDXSPScorePB);
delete TestingIIDXSPScorePB._id;
t.beforeEach(async () => {
await db["personal-bests"].insert(TestingIIDXSPScorePB);
delete TestingIIDXSPScorePB._id;
await db["personal-bests"].insert(
deepmerge(TestingIIDXSPScorePB, {
scoreData: { lamp: "CLEAR", lampIndex: 4 },
chartID: "fake_other_chart_id",
})
);
});
await db["personal-bests"].insert(
deepmerge(TestingIIDXSPScorePB, {
scoreData: { lamp: "CLEAR", lampIndex: 4 },
chartID: "fake_other_chart_id",
})
);
});
t.test("Should work if 511 is >= HARD CLEAR", async (t) => {
const res = await EvaluateGoalForUser(multiGoal, 1, logger);
t.test("Should work if 511 is >= HARD CLEAR", async (t) => {
const res = await EvaluateGoalForUser(multiGoal, 1, logger);
t.strictSame(
res,
{
achieved: true,
outOf: 5,
progress: 6,
outOfHuman: "HARD CLEAR",
progressHuman: "EX HARD CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user succeeds."
);
t.strictSame(
res,
{
achieved: true,
outOf: 5,
progress: 6,
outOfHuman: "HARD CLEAR",
progressHuman: "EX HARD CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user succeeds."
);
t.end();
});
t.end();
});
t.test("Should work if other chart is >= HARD CLEAR", async (t) => {
await db["personal-bests"].update(
{ userID: 1, chartID: Testing511SPA.chartID },
{ $set: { "scoreData.lamp": "CLEAR", "scoreData.lampIndex": 4 } }
);
await db["personal-bests"].update(
{ userID: 1, chartID: "fake_other_chart_id" },
{ $set: { "scoreData.lamp": "HARD CLEAR", "scoreData.lampIndex": 5 } }
);
t.test("Should work if other chart is >= HARD CLEAR", async (t) => {
await db["personal-bests"].update(
{ userID: 1, chartID: Testing511SPA.chartID },
{ $set: { "scoreData.lamp": "CLEAR", "scoreData.lampIndex": 4 } }
);
await db["personal-bests"].update(
{ userID: 1, chartID: "fake_other_chart_id" },
{ $set: { "scoreData.lamp": "HARD CLEAR", "scoreData.lampIndex": 5 } }
);
const res = await EvaluateGoalForUser(multiGoal, 1, logger);
const res = await EvaluateGoalForUser(multiGoal, 1, logger);
t.strictSame(
res,
{
achieved: true,
outOf: 5,
progress: 5,
outOfHuman: "HARD CLEAR",
progressHuman: "HARD CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user succeeds."
);
t.strictSame(
res,
{
achieved: true,
outOf: 5,
progress: 5,
outOfHuman: "HARD CLEAR",
progressHuman: "HARD CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user succeeds."
);
t.end();
});
t.end();
});
t.test("Should work if user has no scores", async (t) => {
await db["personal-bests"].remove({});
t.test("Should work if user has no scores", async (t) => {
await db["personal-bests"].remove({});
const res = await EvaluateGoalForUser(multiGoal, 1, logger);
const res = await EvaluateGoalForUser(multiGoal, 1, logger);
t.strictSame(
res,
{
achieved: false,
outOf: 5,
progress: null,
outOfHuman: "HARD CLEAR",
progressHuman: "NO DATA",
},
"Should correctly evaluate the goal if the user has no data."
);
t.strictSame(
res,
{
achieved: false,
outOf: 5,
progress: null,
outOfHuman: "HARD CLEAR",
progressHuman: "NO DATA",
},
"Should correctly evaluate the goal if the user has no data."
);
t.end();
});
t.end();
});
t.end();
});
t.end();
});
t.test("Should correctly evaluate against folder goals.", (t) => {
// @ts-expect-error ???
// this goal is effectively "HC any of the sp10s"
const folderGoal: GoalDocument = deepmerge(HC511Goal, {
charts: {
type: "folder",
data: "ed9d8c734447ce67d7135c0067441a98cc81aeaf",
},
});
t.test("Should correctly evaluate against folder goals.", (t) => {
// @ts-expect-error ???
// this goal is effectively "HC any of the sp10s"
const folderGoal: GoalDocument = deepmerge(HC511Goal, {
charts: {
type: "folder",
data: "ed9d8c734447ce67d7135c0067441a98cc81aeaf",
},
});
t.beforeEach(async () => {
await db["personal-bests"].insert(TestingIIDXSPScorePB);
delete TestingIIDXSPScorePB._id;
t.beforeEach(async () => {
await db["personal-bests"].insert(TestingIIDXSPScorePB);
delete TestingIIDXSPScorePB._id;
await db["personal-bests"].insert(
deepmerge(TestingIIDXSPScorePB, {
scoreData: { lamp: "CLEAR", lampIndex: 4 },
chartID: "other_sp10",
})
);
await db["personal-bests"].insert(
deepmerge(TestingIIDXSPScorePB, {
scoreData: { lamp: "CLEAR", lampIndex: 4 },
chartID: "other_sp10",
})
);
delete Testing511SPA._id;
delete Testing511SPA._id;
await db.charts.iidx.insert([
// @ts-expect-error ???
deepmerge(Testing511SPA, {
songID: 123,
level: "9",
chartID: "not_sp10",
}),
// @ts-expect-error ???
deepmerge(Testing511SPA, {
songID: 124,
level: "10",
chartID: "other_sp10",
}),
]);
await db.charts.iidx.insert([
// @ts-expect-error ???
deepmerge(Testing511SPA, {
songID: 123,
level: "9",
chartID: "not_sp10",
}),
// @ts-expect-error ???
deepmerge(Testing511SPA, {
songID: 124,
level: "10",
chartID: "other_sp10",
}),
]);
await CreateFolderChartLookup(TestingIIDXFolderSP10);
});
await CreateFolderChartLookup(TestingIIDXFolderSP10);
});
t.test("Should work if 511 is >= HARD CLEAR", async (t) => {
const res = await EvaluateGoalForUser(folderGoal, 1, logger);
t.test("Should work if 511 is >= HARD CLEAR", async (t) => {
const res = await EvaluateGoalForUser(folderGoal, 1, logger);
t.strictSame(
res,
{
achieved: true,
outOf: 5,
progress: 6,
outOfHuman: "HARD CLEAR",
progressHuman: "EX HARD CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user succeeds."
);
t.strictSame(
res,
{
achieved: true,
outOf: 5,
progress: 6,
outOfHuman: "HARD CLEAR",
progressHuman: "EX HARD CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user succeeds."
);
t.end();
});
t.end();
});
t.test("Should work if other chart is >= HARD CLEAR", async (t) => {
await db["personal-bests"].update(
{ userID: 1, chartID: Testing511SPA.chartID },
{ $set: { "scoreData.lamp": "CLEAR", "scoreData.lampIndex": 4 } }
);
t.test("Should work if other chart is >= HARD CLEAR", async (t) => {
await db["personal-bests"].update(
{ userID: 1, chartID: Testing511SPA.chartID },
{ $set: { "scoreData.lamp": "CLEAR", "scoreData.lampIndex": 4 } }
);
await db["personal-bests"].update(
{ userID: 1, chartID: "other_sp10" },
{ $set: { "scoreData.lamp": "HARD CLEAR", "scoreData.lampIndex": 5 } }
);
await db["personal-bests"].update(
{ userID: 1, chartID: "other_sp10" },
{ $set: { "scoreData.lamp": "HARD CLEAR", "scoreData.lampIndex": 5 } }
);
const res = await EvaluateGoalForUser(folderGoal, 1, logger);
const res = await EvaluateGoalForUser(folderGoal, 1, logger);
t.strictSame(
res,
{
achieved: true,
outOf: 5,
progress: 5,
outOfHuman: "HARD CLEAR",
progressHuman: "HARD CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user succeeds."
);
t.strictSame(
res,
{
achieved: true,
outOf: 5,
progress: 5,
outOfHuman: "HARD CLEAR",
progressHuman: "HARD CLEAR (BP: 2)",
},
"Should correctly evaluate the goal if the user succeeds."
);
t.end();
});
t.end();
});
t.test("Should work if user has no scores", async (t) => {
await db["personal-bests"].remove({});
t.test("Should work if user has no scores", async (t) => {
await db["personal-bests"].remove({});
const res = await EvaluateGoalForUser(folderGoal, 1, logger);
const res = await EvaluateGoalForUser(folderGoal, 1, logger);
t.strictSame(
res,
{
achieved: false,
outOf: 5,
progress: null,
outOfHuman: "HARD CLEAR",
progressHuman: "NO DATA",
},
"Should correctly evaluate the goal if the user has no data."
);
t.strictSame(
res,
{
achieved: false,
outOf: 5,
progress: null,
outOfHuman: "HARD CLEAR",
progressHuman: "NO DATA",
},
"Should correctly evaluate the goal if the user has no data."
);
t.end();
});
t.end();
});
t.end();
});
t.end();
});
t.end();
t.end();
});
t.test("#HumaniseGoalProgress", (t) => {
t.equal(HumaniseGoalProgress("iidx", "scoreData.gradeIndex", 4, null), "B");
t.equal(HumaniseGoalProgress("iidx", "scoreData.lampIndex", 4, null), "CLEAR");
t.equal(HumaniseGoalProgress("iidx", "scoreData.percent", 90.1142, null), "90.11%");
t.equal(HumaniseGoalProgress("iidx", "scoreData.score", 2240, null), "2240");
t.equal(HumaniseGoalProgress("iidx", "scoreData.gradeIndex", 4, null), "B");
t.equal(HumaniseGoalProgress("iidx", "scoreData.lampIndex", 4, null), "CLEAR");
t.equal(HumaniseGoalProgress("iidx", "scoreData.percent", 90.1142, null), "90.11%");
t.equal(HumaniseGoalProgress("iidx", "scoreData.score", 2240, null), "2240");
t.end();
t.end();
});
t.teardown(CloseAllConnections);
+152 -152
View File
@@ -7,11 +7,11 @@ import { FilterQuery } from "mongodb";
import fjsh from "fast-json-stable-hash";
export interface EvaluatedGoalReturn {
achieved: boolean;
progress: number | null;
outOf: number;
progressHuman: string;
outOfHuman: string;
achieved: boolean;
progress: number | null;
outOf: number;
progressHuman: string;
outOfHuman: string;
}
/**
@@ -26,146 +26,146 @@ export interface EvaluatedGoalReturn {
* to multiple games.
*/
export function CreateGoalID(charts: GoalDocument["charts"], criteria: GoalDocument["criteria"]) {
return fjsh.hash({ charts, criteria }, "sha256");
return fjsh.hash({ charts, criteria }, "sha256");
}
export async function EvaluateGoalForUser(
goal: GoalDocument,
userID: integer,
logger: KtLogger
goal: GoalDocument,
userID: integer,
logger: KtLogger
): Promise<EvaluatedGoalReturn | null> {
// First, we need to resolve the set of charts this
// goal involves.
const chartIDs = await ResolveGoalCharts(goal);
// First, we need to resolve the set of charts this
// goal involves.
const chartIDs = await ResolveGoalCharts(goal);
if (chartIDs === undefined) {
logger.error(
`Invalid goal ${goal.goalID} - has nonsense chartsType of ${goal.charts.type}, ignoring.`
);
return null;
}
if (chartIDs === undefined) {
logger.error(
`Invalid goal ${goal.goalID} - has nonsense chartsType of ${goal.charts.type}, ignoring.`
);
return null;
}
// lets configure a "base" query for our requests.
const scoreQuery: FilterQuery<PBScoreDocument> = {
userID,
// normally, this would be a VERY WORRYING line of code, but goal.criteria.key is guaranteed to be
// within a specific set of fields.
[goal.criteria.key]: { $gte: goal.criteria.value },
};
// lets configure a "base" query for our requests.
const scoreQuery: FilterQuery<PBScoreDocument> = {
userID,
// normally, this would be a VERY WORRYING line of code, but goal.criteria.key is guaranteed to be
// within a specific set of fields.
[goal.criteria.key]: { $gte: goal.criteria.value },
};
if (chartIDs) {
scoreQuery.chartID = { $in: chartIDs };
}
if (chartIDs) {
scoreQuery.chartID = { $in: chartIDs };
}
// Next, we need to figure out our criteria.
if (goal.criteria.mode === "single") {
const res = await db["personal-bests"].findOne(scoreQuery);
// hack, but guaranteed to work.
const scoreDataKey = goal.criteria.key.split(".")[1] as
| "lampIndex"
| "gradeIndex"
| "score"
| "percent";
// Next, we need to figure out our criteria.
if (goal.criteria.mode === "single") {
const res = await db["personal-bests"].findOne(scoreQuery);
// hack, but guaranteed to work.
const scoreDataKey = goal.criteria.key.split(".")[1] as
| "lampIndex"
| "gradeIndex"
| "score"
| "percent";
const outOfHuman = HumaniseGoalProgress(
goal.game,
goal.criteria.key,
goal.criteria.value,
null
);
const outOfHuman = HumaniseGoalProgress(
goal.game,
goal.criteria.key,
goal.criteria.value,
null
);
if (res) {
return {
achieved: true,
outOf: goal.criteria.value,
progress: res.scoreData[scoreDataKey],
outOfHuman,
progressHuman: HumaniseGoalProgress(
goal.game,
goal.criteria.key,
res.scoreData[scoreDataKey],
res
),
};
}
if (res) {
return {
achieved: true,
outOf: goal.criteria.value,
progress: res.scoreData[scoreDataKey],
outOfHuman,
progressHuman: HumaniseGoalProgress(
goal.game,
goal.criteria.key,
res.scoreData[scoreDataKey],
res
),
};
}
// if we weren't successful, we have to get the users next best score and put it up here
// this is made infinitely easier by the existance of personal-bests.
const nextBestQuery: FilterQuery<PBScoreDocument> = {
userID,
};
// if we weren't successful, we have to get the users next best score and put it up here
// this is made infinitely easier by the existance of personal-bests.
const nextBestQuery: FilterQuery<PBScoreDocument> = {
userID,
};
if (chartIDs) {
nextBestQuery.chartID = { $in: chartIDs };
}
if (chartIDs) {
nextBestQuery.chartID = { $in: chartIDs };
}
const nextBestScore = await db["personal-bests"].findOne(nextBestQuery, {
sort: { [goal.criteria.key]: -1 },
});
const nextBestScore = await db["personal-bests"].findOne(nextBestQuery, {
sort: { [goal.criteria.key]: -1 },
});
if (!nextBestScore) {
return {
achieved: false,
outOf: goal.criteria.value,
progress: null,
outOfHuman,
progressHuman: "NO DATA",
};
}
if (!nextBestScore) {
return {
achieved: false,
outOf: goal.criteria.value,
progress: null,
outOfHuman,
progressHuman: "NO DATA",
};
}
return {
achieved: false,
outOf: goal.criteria.value,
outOfHuman,
progress: nextBestScore.scoreData[scoreDataKey],
progressHuman: HumaniseGoalProgress(
goal.game,
goal.criteria.key,
nextBestScore.scoreData[scoreDataKey],
nextBestScore
),
};
} else if (goal.criteria.mode === "abs" || goal.criteria.mode === "proportion") {
let count;
return {
achieved: false,
outOf: goal.criteria.value,
outOfHuman,
progress: nextBestScore.scoreData[scoreDataKey],
progressHuman: HumaniseGoalProgress(
goal.game,
goal.criteria.key,
nextBestScore.scoreData[scoreDataKey],
nextBestScore
),
};
} else if (goal.criteria.mode === "abs" || goal.criteria.mode === "proportion") {
let count;
// abs -> Absolute mode, such as clear 10 charts.
if (goal.criteria.mode === "abs") {
count = goal.criteria.countNum;
} else {
// proportion -> Proportional mode, the value
// is a multiplier for the amount of charts
// available -- i.e. 0.1 * charts.
// abs -> Absolute mode, such as clear 10 charts.
if (goal.criteria.mode === "abs") {
count = goal.criteria.countNum;
} else {
// proportion -> Proportional mode, the value
// is a multiplier for the amount of charts
// available -- i.e. 0.1 * charts.
let totalChartCount;
let totalChartCount;
if (chartIDs === null) {
// edge case: proportion goals on "any"
// charts (i.e. clear 20% of charts) need to
// know how many charts the game has!
totalChartCount = await db.charts[goal.game].count({ playtype: goal.playtype });
} else {
totalChartCount = chartIDs.length;
}
if (chartIDs === null) {
// edge case: proportion goals on "any"
// charts (i.e. clear 20% of charts) need to
// know how many charts the game has!
totalChartCount = await db.charts[goal.game].count({ playtype: goal.playtype });
} else {
totalChartCount = chartIDs.length;
}
count = Math.floor(goal.criteria.countNum * totalChartCount);
}
count = Math.floor(goal.criteria.countNum * totalChartCount);
}
const userCount = await db["personal-bests"].count(scoreQuery);
const userCount = await db["personal-bests"].count(scoreQuery);
return {
achieved: userCount >= count,
progress: userCount,
outOf: count,
progressHuman: userCount.toString(),
outOfHuman: userCount.toString(),
};
}
return {
achieved: userCount >= count,
progress: userCount,
outOf: count,
progressHuman: userCount.toString(),
outOfHuman: userCount.toString(),
};
}
logger.error(
`Invalid goal: ${goal.goalID}, unknown criteria.mode ${goal.criteria.mode}, ignoring.`
);
logger.error(
`Invalid goal: ${goal.goalID}, unknown criteria.mode ${goal.criteria.mode}, ignoring.`
);
return null;
return null;
}
/**
@@ -174,15 +174,15 @@ export async function EvaluateGoalForUser(
* @returns An array of chartIDs, except if the goal chart type is "any", in which case, it returns null.
*/
function ResolveGoalCharts(goal: GoalDocument): Promise<string[]> | string[] | null | undefined {
if (goal.charts.type === "single") {
return [goal.charts.data];
} else if (goal.charts.type === "multi") {
return goal.charts.data;
} else if (goal.charts.type === "folder") {
return GetFolderChartIDs(goal.charts.data);
} else if (goal.charts.type === "any") {
return null; // special case.
}
if (goal.charts.type === "single") {
return [goal.charts.data];
} else if (goal.charts.type === "multi") {
return goal.charts.data;
} else if (goal.charts.type === "folder") {
return GetFolderChartIDs(goal.charts.data);
} else if (goal.charts.type === "any") {
return null; // special case.
}
}
type GoalKeys = GoalDocument["criteria"]["key"];
@@ -191,26 +191,26 @@ type IIDXOrBMSPB = PBScoreDocument<"iidx:SP" | "iidx:DP" | "bms:7K" | "bms:14K">
// @todo, #100 improve this (add things like BP for iidx, maybe, percents for scores?)
export function HumaniseGoalProgress(
game: Game,
key: GoalKeys,
value: number,
userPB: PBScoreDocument | null
game: Game,
key: GoalKeys,
value: number,
userPB: PBScoreDocument | null
): string {
switch (key) {
case "scoreData.gradeIndex":
return grades[game][value];
case "scoreData.lampIndex":
if (userPB && (game === "iidx" || game === "bms")) {
return `${lamps[game][value]} (BP: ${
(userPB as IIDXOrBMSPB).scoreData.hitMeta.bp ?? "N/A"
})`;
}
return lamps[game][value];
case "scoreData.percent":
return `${value.toFixed(2)}%`;
case "scoreData.score":
return value.toString();
default:
throw new Error(`Broken goal - invalid key ${key}.`);
}
switch (key) {
case "scoreData.gradeIndex":
return grades[game][value];
case "scoreData.lampIndex":
if (userPB && (game === "iidx" || game === "bms")) {
return `${lamps[game][value]} (BP: ${
(userPB as IIDXOrBMSPB).scoreData.hitMeta.bp ?? "N/A"
})`;
}
return lamps[game][value];
case "scoreData.percent":
return `${value.toFixed(2)}%`;
case "scoreData.score":
return value.toString();
default:
throw new Error(`Broken goal - invalid key ${key}.`);
}
}
+8 -8
View File
@@ -14,16 +14,16 @@ const logger = CreateLogCtx(__filename);
* Joins a file location against the location of the CDN static store.
*/
function CDNRoot(fileLoc: string) {
return path.join(KTCDN_ROOT, fileLoc);
return path.join(KTCDN_ROOT, fileLoc);
}
/**
* Retrieves the data of the file at the given CDN location.
*/
export function RetrieveCDN(fileLoc: string) {
logger.debug(`Retrieving path ${fileLoc}.`);
logger.debug(`Retrieving path ${fileLoc}.`);
return readFilePromise(CDNRoot(fileLoc));
return readFilePromise(CDNRoot(fileLoc));
}
/**
@@ -36,12 +36,12 @@ const WRITE_NO_OVERWRITE = "wx";
* @returns Nothing on success. Throws on error.
*/
export async function StoreCDN(fileLoc: string, data: Buffer | string) {
logger.debug(`Storing path ${fileLoc}.`);
logger.debug(`Storing path ${fileLoc}.`);
const loc = CDNRoot(fileLoc);
const loc = CDNRoot(fileLoc);
// make the parent folders if they dont exist. else, mkdirp is a no-op.
await mkdirp(path.dirname(loc));
// make the parent folders if they dont exist. else, mkdirp is a no-op.
await mkdirp(path.dirname(loc));
return writeFilePromise(loc, data, { flag: WRITE_NO_OVERWRITE });
return writeFilePromise(loc, data, { flag: WRITE_NO_OVERWRITE });
}
+136 -136
View File
@@ -1,161 +1,161 @@
export enum IIDXClasses {
KYU_7,
KYU_6,
KYU_5,
KYU_4,
KYU_3,
KYU_2,
KYU_1,
KYU_7,
KYU_6,
KYU_5,
KYU_4,
KYU_3,
KYU_2,
KYU_1,
DAN_1,
DAN_2,
DAN_3,
DAN_4,
DAN_5,
DAN_6,
DAN_7,
DAN_8,
DAN_9,
DAN_10,
DAN_1,
DAN_2,
DAN_3,
DAN_4,
DAN_5,
DAN_6,
DAN_7,
DAN_8,
DAN_9,
DAN_10,
CHUUDEN,
KAIDEN,
CHUUDEN,
KAIDEN,
}
export enum GitadoraColours {
WHITE,
WHITE,
ORANGE,
ORANGE_GRADIENT,
YELLOW,
YELLOW_GRADIENT,
GREEN,
GREEN_GRADIENT,
BLUE,
BLUE_GRADIENT,
PURPLE,
PURPLE_GRADIENT,
RED,
RED_GRADIENT,
ORANGE,
ORANGE_GRADIENT,
YELLOW,
YELLOW_GRADIENT,
GREEN,
GREEN_GRADIENT,
BLUE,
BLUE_GRADIENT,
PURPLE,
PURPLE_GRADIENT,
RED,
RED_GRADIENT,
BRONZE,
SILVER,
GOLD,
RAINBOW,
BRONZE,
SILVER,
GOLD,
RAINBOW,
}
export enum BMSGenocideDans {
NORMAL_1,
NORMAL_2,
NORMAL_3,
NORMAL_4,
NORMAL_5,
NORMAL_6,
NORMAL_7,
NORMAL_8,
NORMAL_9,
NORMAL_10,
NORMAL_1,
NORMAL_2,
NORMAL_3,
NORMAL_4,
NORMAL_5,
NORMAL_6,
NORMAL_7,
NORMAL_8,
NORMAL_9,
NORMAL_10,
INSANE_1,
INSANE_2,
INSANE_3,
INSANE_4,
INSANE_5,
INSANE_6,
INSANE_7,
INSANE_8,
INSANE_9,
INSANE_10,
INSANE_KAIDEN,
INSANE_1,
INSANE_2,
INSANE_3,
INSANE_4,
INSANE_5,
INSANE_6,
INSANE_7,
INSANE_8,
INSANE_9,
INSANE_10,
INSANE_KAIDEN,
OVERJOY,
OVERJOY,
}
export enum BMSStSlDans {
SL_0,
SL_1,
SL_2,
SL_3,
SL_4,
SL_5,
SL_6,
SL_7,
SL_8,
SL_9,
SL_10,
SL_11,
SL_12,
SL_0,
SL_1,
SL_2,
SL_3,
SL_4,
SL_5,
SL_6,
SL_7,
SL_8,
SL_9,
SL_10,
SL_11,
SL_12,
ST_0,
ST_1,
ST_2,
ST_3,
ST_4,
ST_5,
ST_6,
ST_7,
ST_8,
ST_9,
ST_10,
ST_11,
ST_12,
ST_0,
ST_1,
ST_2,
ST_3,
ST_4,
ST_5,
ST_6,
ST_7,
ST_8,
ST_9,
ST_10,
ST_11,
ST_12,
}
export enum SDVXDans {
DAN_1,
DAN_2,
DAN_3,
DAN_4,
DAN_5,
DAN_6,
DAN_7,
DAN_8,
DAN_9,
DAN_10,
DAN_11,
INF,
DAN_1,
DAN_2,
DAN_3,
DAN_4,
DAN_5,
DAN_6,
DAN_7,
DAN_8,
DAN_9,
DAN_10,
DAN_11,
INF,
}
// ewww
export enum SDVXVFClasses {
SIENNA_I,
SIENNA_II,
SIENNA_III,
SIENNA_IV,
COBALT_I,
COBALT_II,
COBALT_III,
COBALT_IV,
DANDELION_I,
DANDELION_II,
DANDELION_III,
DANDELION_IV,
CYAN_I,
CYAN_II,
CYAN_III,
CYAN_IV,
SCARLET_I,
SCARLET_II,
SCARLET_III,
SCARLET_IV,
CORAL_I,
CORAL_II,
CORAL_III,
CORAL_IV,
ARGENTO_I,
ARGENTO_II,
ARGENTO_III,
ARGENTO_IV,
ELDORA_I,
ELDORA_II,
ELDORA_III,
ELDORA_IV,
CRIMSON_I,
CRIMSON_II,
CRIMSON_III,
CRIMSON_IV,
IMPERIAL_I,
IMPERIAL_II,
IMPERIAL_III,
IMPERIAL_IV,
SIENNA_I,
SIENNA_II,
SIENNA_III,
SIENNA_IV,
COBALT_I,
COBALT_II,
COBALT_III,
COBALT_IV,
DANDELION_I,
DANDELION_II,
DANDELION_III,
DANDELION_IV,
CYAN_I,
CYAN_II,
CYAN_III,
CYAN_IV,
SCARLET_I,
SCARLET_II,
SCARLET_III,
SCARLET_IV,
CORAL_I,
CORAL_II,
CORAL_III,
CORAL_IV,
ARGENTO_I,
ARGENTO_II,
ARGENTO_III,
ARGENTO_IV,
ELDORA_I,
ELDORA_II,
ELDORA_III,
ELDORA_IV,
CRIMSON_I,
CRIMSON_II,
CRIMSON_III,
CRIMSON_IV,
IMPERIAL_I,
IMPERIAL_II,
IMPERIAL_III,
IMPERIAL_IV,
}
+17 -17
View File
@@ -11,29 +11,29 @@ const PATCH = 0;
// The fact that this album is very similar to Mezzanine
// - the album used for Kamaitachi v1 - is deliberate. :) - zkldi
const PORTISHEAD_DUMMY = [
"Mysterons", // v2.0.0
"Sour Times", // v2.1.0
"Strangers", // v2.2.0
"It Could Be Sweet", // v2.3.0
"Wandering Star", // v2.4.0
"It's a Fire", // v2.5.0
"Numb", // v2.6.0
"Roads", // v2.7.0
"Pedestal", // v2.8.0
"Biscuit", // v2.9.0
"Glory Box", // v2.10.0
// v2.11+ onwards are not expected to occur, but if they do, we'll figure something out.
"Mysterons", // v2.0.0
"Sour Times", // v2.1.0
"Strangers", // v2.2.0
"It Could Be Sweet", // v2.3.0
"Wandering Star", // v2.4.0
"It's a Fire", // v2.5.0
"Numb", // v2.6.0
"Roads", // v2.7.0
"Pedestal", // v2.8.0
"Biscuit", // v2.9.0
"Glory Box", // v2.10.0
// v2.11+ onwards are not expected to occur, but if they do, we'll figure something out.
];
export const VERSION_INFO = {
major: MAJOR,
minor: MINOR,
patch: PATCH,
name: PORTISHEAD_DUMMY[MINOR],
major: MAJOR,
minor: MINOR,
patch: PATCH,
name: PORTISHEAD_DUMMY[MINOR],
};
export const VERSION_STR = `${MAJOR}.${MINOR}.${PATCH}`;
export function FormatVersion() {
return `v${VERSION_STR} (${VERSION_INFO.name})`;
return `v${VERSION_STR} (${VERSION_INFO.name})`;
}
+12 -12
View File
@@ -3,23 +3,23 @@ import { CloseAllConnections } from "../../test-utils/close-connections";
import CreateLogCtx, { Transports } from "./logger";
t.test("Logger Tests", (t) => {
const logger = CreateLogCtx(__filename);
const logger = CreateLogCtx(__filename);
Transports[0].level = "debug"; // lol
Transports[0].level = "debug"; // lol
logger.debug("Debug Message Test");
logger.verbose("Verbose Message Test");
logger.info("Info Message Test");
logger.warn("Warning Message Test");
logger.error("Error Message Test");
logger.severe("Severe Message Test");
logger.crit("Critical Message Test");
logger.debug("Debug Message Test");
logger.verbose("Verbose Message Test");
logger.info("Info Message Test");
logger.warn("Warning Message Test");
logger.error("Error Message Test");
logger.severe("Severe Message Test");
logger.crit("Critical Message Test");
Transports[0].level = process.env.LOG_LEVEL ?? "info";
Transports[0].level = process.env.LOG_LEVEL ?? "info";
logger.debug("This message shouldn't appear.");
logger.debug("This message shouldn't appear.");
t.end();
t.end();
});
t.teardown(CloseAllConnections);
+96 -96
View File
@@ -10,150 +10,150 @@ const level = LOG_LEVEL ?? "info";
const IN_TESTING = process.env.NODE_ENV === "test";
const formatExcessProperties = (meta: Record<string, unknown>) => {
let i = 0;
for (const key in meta) {
const val = meta[key];
let i = 0;
for (const key in meta) {
const val = meta[key];
if (val instanceof Error) {
meta[key] = { message: val.message, stack: val.stack };
}
i++;
}
if (val instanceof Error) {
meta[key] = { message: val.message, stack: val.stack };
}
i++;
}
if (!i) {
return "";
}
if (!i) {
return "";
}
return ` ${JSON.stringify(meta)}`;
return ` ${JSON.stringify(meta)}`;
};
const formatExcessPropertiesNoStack = (meta: Record<string, unknown>, omitKeys: string[] = []) => {
let i = 0;
const realMeta: Record<string, unknown> = {};
let i = 0;
const realMeta: Record<string, unknown> = {};
for (const key in meta) {
if (omitKeys.includes(key)) {
continue;
}
for (const key in meta) {
if (omitKeys.includes(key)) {
continue;
}
const val = meta[key];
const val = meta[key];
if (val instanceof Error) {
realMeta[key] = { message: val.message };
} else {
realMeta[key] = val;
}
i++;
}
if (val instanceof Error) {
realMeta[key] = { message: val.message };
} else {
realMeta[key] = val;
}
i++;
}
if (!i) {
return "";
}
if (!i) {
return "";
}
return ` ${JSON.stringify(realMeta)}`;
return ` ${JSON.stringify(realMeta)}`;
};
const ktblackPrintf = format.printf(
({ level, message, context = "ktblack-root", timestamp, ...meta }) =>
`${timestamp} [${
Array.isArray(context) ? context.join(" | ") : context
}] ${level}: ${message}${formatExcessProperties(meta)}`
({ level, message, context = "ktblack-root", timestamp, ...meta }) =>
`${timestamp} [${
Array.isArray(context) ? context.join(" | ") : context
}] ${level}: ${message}${formatExcessProperties(meta)}`
);
const ktblackConsolePrintf = format.printf(
({ level, message, context = "ktblack-root", timestamp, hideFromConsole, ...meta }) =>
`${timestamp} [${
Array.isArray(context) ? context.join(" | ") : context
}] ${level}: ${message}${formatExcessPropertiesNoStack(meta, hideFromConsole)}`
({ level, message, context = "ktblack-root", timestamp, hideFromConsole, ...meta }) =>
`${timestamp} [${
Array.isArray(context) ? context.join(" | ") : context
}] ${level}: ${message}${formatExcessPropertiesNoStack(meta, hideFromConsole)}`
);
winston.addColors({
crit: ["bgRed", "black"],
severe: ["bgBrightRed", "black"],
error: ["red"],
warn: ["yellow"],
info: ["blue"],
verbose: ["cyan"],
debug: ["white"],
crit: ["bgRed", "black"],
severe: ["bgBrightRed", "black"],
error: ["red"],
warn: ["yellow"],
info: ["blue"],
verbose: ["cyan"],
debug: ["white"],
});
const baseFormatRoute = format.combine(
format.timestamp({
format: "YYYY-MM-DD HH:mm:ss",
})
format.timestamp({
format: "YYYY-MM-DD HH:mm:ss",
})
);
const defaultFormatRoute = format.combine(
baseFormatRoute,
format.errors({ stack: false }),
ktblackPrintf
baseFormatRoute,
format.errors({ stack: false }),
ktblackPrintf
);
const consoleFormatRoute = format.combine(
baseFormatRoute,
format.errors({ stack: false }),
ktblackConsolePrintf,
format.colorize({
all: true,
})
baseFormatRoute,
format.errors({ stack: false }),
ktblackConsolePrintf,
format.colorize({
all: true,
})
);
let tports = [];
/* istanbul ignore next */
if (IN_TESTING) {
tports = [
new transports.Console({
format: consoleFormatRoute,
}),
];
tports = [
new transports.Console({
format: consoleFormatRoute,
}),
];
} else {
tports = [
new transports.File({
filename: "logs/ktblack-error.log",
level: "error",
format: defaultFormatRoute,
}),
new transports.File({ filename: "logs/ktblack.log", format: defaultFormatRoute }),
new transports.Console({
format: consoleFormatRoute,
}),
];
tports = [
new transports.File({
filename: "logs/ktblack-error.log",
level: "error",
format: defaultFormatRoute,
}),
new transports.File({ filename: "logs/ktblack.log", format: defaultFormatRoute }),
new transports.Console({
format: consoleFormatRoute,
}),
];
}
export const rootLogger = winston.createLogger({
levels: {
crit: 0, // entire process termination is necessary
severe: 1, // something is wrong, and more than one function is affected (such as a failed assertion that is definitely expected to be true).
error: 2, // function call (or related process) has failed unexpectedly
warn: 3, // function call has hit something it didn't want, but can recover
info: 4, // something has happened that is expected, but worth logging
verbose: 5, // something has happened
debug: 6, // glorified console.log debugging
},
level,
format: defaultFormatRoute,
transports: tports,
levels: {
crit: 0, // entire process termination is necessary
severe: 1, // something is wrong, and more than one function is affected (such as a failed assertion that is definitely expected to be true).
error: 2, // function call (or related process) has failed unexpectedly
warn: 3, // function call has hit something it didn't want, but can recover
info: 4, // something has happened that is expected, but worth logging
verbose: 5, // something has happened
debug: 6, // glorified console.log debugging
},
level,
format: defaultFormatRoute,
transports: tports,
});
function CreateLogCtx(filename: string, lg = rootLogger): KtLogger {
const replacedFilename = filename.replace(
new RegExp(`^${EscapeStringRegexp(process.cwd())}/((js|src)/)?`, "u"),
""
);
const replacedFilename = filename.replace(
new RegExp(`^${EscapeStringRegexp(process.cwd())}/((js|src)/)?`, "u"),
""
);
const logger = lg.child({
context: [replacedFilename],
}) as KtLogger;
const logger = lg.child({
context: [replacedFilename],
}) as KtLogger;
logger.defaultMeta = { context: [replacedFilename] };
return logger;
logger.defaultMeta = { context: [replacedFilename] };
return logger;
}
export function AppendLogCtx(context: string, lg: KtLogger): KtLogger {
const newContext = [...lg.defaultMeta.context, context];
const newContext = [...lg.defaultMeta.context, context];
return lg.child({ context: newContext }) as KtLogger;
return lg.child({ context: newContext }) as KtLogger;
}
export const Transports = tports;
@@ -4,11 +4,11 @@ import db from "../../../../external/mongo/db";
import CreateLogCtx from "../../../logger/logger";
import { prAssert } from "../../../../test-utils/asserts";
import {
Testing511SPA,
TestingDoraChart,
TestingGITADORADoraDryScore,
TestingIIDXSPDryScore,
TestingSDVXSingleDryScore,
Testing511SPA,
TestingDoraChart,
TestingGITADORADoraDryScore,
TestingIIDXSPDryScore,
TestingSDVXSingleDryScore,
} from "../../../../test-utils/test-data";
import { CreateCalculatedData, CalculateDataForGamePT } from "./calculated-data";
import deepmerge from "deepmerge";
@@ -18,374 +18,374 @@ import { CloseAllConnections } from "../../../../test-utils/close-connections";
const logger = CreateLogCtx(__filename);
t.test("#CreateCalculatedData", async (t) => {
const res = await CreateCalculatedData(TestingIIDXSPDryScore, Testing511SPA, 30, logger);
const res = await CreateCalculatedData(TestingIIDXSPDryScore, Testing511SPA, 30, logger);
prAssert(
res,
{
ktRating: p.aprx(2.65),
ktLampRating: p.equalTo(10),
BPI: "?number",
"K%": "?number",
},
"Should correctly produce calculatedData"
);
prAssert(
res,
{
ktRating: p.aprx(2.65),
ktLampRating: p.equalTo(10),
BPI: "?number",
"K%": "?number",
},
"Should correctly produce calculatedData"
);
const gitadoraRes = await CreateCalculatedData(
TestingGITADORADoraDryScore,
TestingDoraChart,
30,
logger
);
const gitadoraRes = await CreateCalculatedData(
TestingGITADORADoraDryScore,
TestingDoraChart,
30,
logger
);
prAssert(
gitadoraRes,
{
skill: p.isPositiveNonZero,
},
"Should correctly call rating function overrides for different games"
);
prAssert(
gitadoraRes,
{
skill: p.isPositiveNonZero,
},
"Should correctly call rating function overrides for different games"
);
t.end();
t.end();
});
/**
* These tests only check that the right properties are assigned.
*/
t.test("#CalculateDataForGamePT", (t) => {
const defaultTierlistID = undefined;
t.test("IIDX:SP", async (t) => {
const res = await CalculateDataForGamePT(
"iidx",
"SP",
Testing511SPA,
TestingIIDXSPDryScore,
30,
defaultTierlistID,
logger
);
const defaultTierlistID = undefined;
t.test("IIDX:SP", async (t) => {
const res = await CalculateDataForGamePT(
"iidx",
"SP",
Testing511SPA,
TestingIIDXSPDryScore,
30,
defaultTierlistID,
logger
);
prAssert(
res,
{
ktRating: "?number",
ktLampRating: "?number",
BPI: "?number",
"K%": "?number",
},
"Response should contain keys for IIDX:SP"
);
prAssert(
res,
{
ktRating: "?number",
ktLampRating: "?number",
BPI: "?number",
"K%": "?number",
},
"Response should contain keys for IIDX:SP"
);
t.end();
});
t.end();
});
t.test("IIDX:DP", async (t) => {
const res = await CalculateDataForGamePT(
"iidx",
"DP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an SP score. but we're testing
30,
defaultTierlistID,
logger
);
t.test("IIDX:DP", async (t) => {
const res = await CalculateDataForGamePT(
"iidx",
"DP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an SP score. but we're testing
30,
defaultTierlistID,
logger
);
prAssert(
res,
{
ktRating: "?number",
ktLampRating: "?number",
BPI: "?number",
},
"Response should contain keys for IIDX:DP"
);
prAssert(
res,
{
ktRating: "?number",
ktLampRating: "?number",
BPI: "?number",
},
"Response should contain keys for IIDX:DP"
);
t.end();
});
t.end();
});
t.test("SDVX:Single", async (t) => {
const res = await CalculateDataForGamePT(
"sdvx",
"Single",
Testing511SPA,
TestingSDVXSingleDryScore,
null,
defaultTierlistID,
logger
);
t.test("SDVX:Single", async (t) => {
const res = await CalculateDataForGamePT(
"sdvx",
"Single",
Testing511SPA,
TestingSDVXSingleDryScore,
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
VF6: p.nullable(p.isPositive),
},
"Response should contain keys for SDVX:Single"
);
prAssert(
res,
{
VF6: p.nullable(p.isPositive),
},
"Response should contain keys for SDVX:Single"
);
t.end();
});
t.end();
});
t.test("DDR:SP", async (t) => {
const res = await CalculateDataForGamePT(
"ddr",
"SP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("DDR:SP", async (t) => {
const res = await CalculateDataForGamePT(
"ddr",
"SP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
MFCP: p.nullable(p.isPositiveInteger),
ktRating: "?number",
},
"Response should contain keys for DDR:SP"
);
prAssert(
res,
{
MFCP: p.nullable(p.isPositiveInteger),
ktRating: "?number",
},
"Response should contain keys for DDR:SP"
);
t.end();
});
t.end();
});
t.test("DDR:DP", async (t) => {
const res = await CalculateDataForGamePT(
"ddr",
"DP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("DDR:DP", async (t) => {
const res = await CalculateDataForGamePT(
"ddr",
"DP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
MFCP: "null",
ktRating: "?number",
},
"Response should contain nulled keys for DDR:DP"
);
prAssert(
res,
{
MFCP: "null",
ktRating: "?number",
},
"Response should contain nulled keys for DDR:DP"
);
t.end();
});
t.end();
});
t.test("chunithm:Single", async (t) => {
const res = await CalculateDataForGamePT(
"chunithm",
"Single",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("chunithm:Single", async (t) => {
const res = await CalculateDataForGamePT(
"chunithm",
"Single",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
rating: "?number",
},
"Response should contain nulled keys for chunithm:Single"
);
prAssert(
res,
{
rating: "?number",
},
"Response should contain nulled keys for chunithm:Single"
);
t.end();
});
t.end();
});
t.test("maimai:Single", async (t) => {
const res = await CalculateDataForGamePT(
"maimai",
"Single",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("maimai:Single", async (t) => {
const res = await CalculateDataForGamePT(
"maimai",
"Single",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
ktRating: "?number",
},
"Response should contain nulled keys for maimai:Single"
);
prAssert(
res,
{
ktRating: "?number",
},
"Response should contain nulled keys for maimai:Single"
);
t.end();
});
t.end();
});
t.test("museca:Single", async (t) => {
const res = await CalculateDataForGamePT(
"museca",
"Single",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("museca:Single", async (t) => {
const res = await CalculateDataForGamePT(
"museca",
"Single",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
ktRating: "?number",
},
"Response should contain nulled keys for museca:Single"
);
prAssert(
res,
{
ktRating: "?number",
},
"Response should contain nulled keys for museca:Single"
);
t.end();
});
t.end();
});
t.test("bms:7K", async (t) => {
const res = await CalculateDataForGamePT(
"bms",
"7K",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("bms:7K", async (t) => {
const res = await CalculateDataForGamePT(
"bms",
"7K",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
ktLampRating: "?number",
},
"Response should contain nulled keys for bms:7K"
);
prAssert(
res,
{
ktLampRating: "?number",
},
"Response should contain nulled keys for bms:7K"
);
t.end();
});
t.end();
});
t.test("bms:14K", async (t) => {
const res = await CalculateDataForGamePT(
"bms",
"14K",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("bms:14K", async (t) => {
const res = await CalculateDataForGamePT(
"bms",
"14K",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
ktLampRating: "?number",
},
"Response should contain nulled keys for bms:14K"
);
prAssert(
res,
{
ktLampRating: "?number",
},
"Response should contain nulled keys for bms:14K"
);
t.end();
});
t.end();
});
t.test("gitadora:Gita", async (t) => {
const res = await CalculateDataForGamePT(
"gitadora",
"Gita",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("gitadora:Gita", async (t) => {
const res = await CalculateDataForGamePT(
"gitadora",
"Gita",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
skill: "?number",
},
"Response should contain nulled keys for gitadora:Gita"
);
prAssert(
res,
{
skill: "?number",
},
"Response should contain nulled keys for gitadora:Gita"
);
t.end();
});
t.end();
});
t.test("gitadora:Dora", async (t) => {
const res = await CalculateDataForGamePT(
"gitadora",
"Dora",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("gitadora:Dora", async (t) => {
const res = await CalculateDataForGamePT(
"gitadora",
"Dora",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
skill: "?number",
},
"Response should contain nulled keys for gitadora:Dora"
);
prAssert(
res,
{
skill: "?number",
},
"Response should contain nulled keys for gitadora:Dora"
);
t.end();
});
t.end();
});
t.test("usc:Single", async (t) => {
const res = await CalculateDataForGamePT(
"usc",
"Single",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("usc:Single", async (t) => {
const res = await CalculateDataForGamePT(
"usc",
"Single",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
VF6: "?number",
},
"Response should contain nulled keys for usc:Single"
);
prAssert(
res,
{
VF6: "?number",
},
"Response should contain nulled keys for usc:Single"
);
t.end();
});
t.end();
});
t.test("jubeat:Single", async (t) => {
const res = await CalculateDataForGamePT(
"jubeat",
"Single",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("jubeat:Single", async (t) => {
const res = await CalculateDataForGamePT(
"jubeat",
"Single",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(
res,
{
jubility: "?number",
},
"Response should contain nulled keys for jubeat:Single"
);
prAssert(
res,
{
jubility: "?number",
},
"Response should contain nulled keys for jubeat:Single"
);
t.end();
});
t.end();
});
t.test("popn:9B", async (t) => {
const res = await CalculateDataForGamePT(
"popn",
"9B",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
t.test("popn:9B", async (t) => {
const res = await CalculateDataForGamePT(
"popn",
"9B",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
null,
defaultTierlistID,
logger
);
prAssert(res, {}, "Response should contain nulled keys for popn:9B");
prAssert(res, {}, "Response should contain nulled keys for popn:9B");
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -1,12 +1,12 @@
import {
AnyChartDocument,
Game,
Playtypes,
ScoreDocument,
ChartDocument,
Grades,
Lamps,
IDStrings,
AnyChartDocument,
Game,
Playtypes,
ScoreDocument,
ChartDocument,
Grades,
Lamps,
IDStrings,
} from "tachi-common";
import db from "../../../../external/mongo/db";
import { HasOwnProperty } from "../../../../utils/misc";
@@ -15,308 +15,308 @@ import { GetDefaultTierlist } from "../../../../utils/tierlist";
import { KtLogger } from "../../../logger/logger";
import { DryScore } from "../common/types";
import {
CalculateCHUNITHMRating,
CalculateGITADORASkill,
CalculateBPI,
CalculateMFCP,
KaidenPercentile,
CalculateKTRating,
CalculateKTLampRating,
CalculateVF6,
CalculateCHUNITHMRating,
CalculateGITADORASkill,
CalculateBPI,
CalculateMFCP,
KaidenPercentile,
CalculateKTRating,
CalculateKTLampRating,
CalculateVF6,
} from "./stats";
export async function CreateCalculatedData(
dryScore: DryScore,
chart: AnyChartDocument,
esd: number | null,
logger: KtLogger
dryScore: DryScore,
chart: AnyChartDocument,
esd: number | null,
logger: KtLogger
): Promise<ScoreDocument["calculatedData"]> {
const game = dryScore.game;
const playtype = chart.playtype;
const game = dryScore.game;
const playtype = chart.playtype;
const defaultTierlist = await GetDefaultTierlist(game, playtype);
const defaultTierlistID = defaultTierlist?.tierlistID; // tierlistID | undefined
const defaultTierlist = await GetDefaultTierlist(game, playtype);
const defaultTierlistID = defaultTierlist?.tierlistID; // tierlistID | undefined
const calculatedData = await CalculateDataForGamePT(
game,
playtype,
chart,
dryScore,
esd,
defaultTierlistID,
logger
);
const calculatedData = await CalculateDataForGamePT(
game,
playtype,
chart,
dryScore,
esd,
defaultTierlistID,
logger
);
return calculatedData;
return calculatedData;
}
type CalculatedDataFunctions = {
[G in Game]: {
[P in Playtypes[G]]: (
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string,
logger: KtLogger
) => Promise<ScoreDocument["calculatedData"]> | ScoreDocument["calculatedData"];
};
[G in Game]: {
[P in Playtypes[G]]: (
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string,
logger: KtLogger
) => Promise<ScoreDocument["calculatedData"]> | ScoreDocument["calculatedData"];
};
};
const CalculatedDataFunctions: CalculatedDataFunctions = {
iidx: {
SP: CalculateDataIIDXSP,
DP: CalculateDataIIDXDP,
},
sdvx: {
Single: CalculateDataSDVXorUSC,
},
popn: {
"9B": () => ({}),
},
museca: {
Single: CalculateDataMuseca,
},
chunithm: {
Single: CalculateDataCHUNITHM,
},
maimai: {
Single: CalculateDataMaimai,
},
gitadora: {
Gita: CalculateDataGitadora,
Dora: CalculateDataGitadora,
},
bms: {
"7K": CalculateDataBMS7K,
"14K": CalculateDataBMS14K,
},
ddr: {
SP: CalculateDataDDR,
DP: CalculateDataDDR,
},
jubeat: {
Single: CalculateDataJubeat,
},
usc: {
Single: CalculateDataSDVXorUSC,
},
iidx: {
SP: CalculateDataIIDXSP,
DP: CalculateDataIIDXDP,
},
sdvx: {
Single: CalculateDataSDVXorUSC,
},
popn: {
"9B": () => ({}),
},
museca: {
Single: CalculateDataMuseca,
},
chunithm: {
Single: CalculateDataCHUNITHM,
},
maimai: {
Single: CalculateDataMaimai,
},
gitadora: {
Gita: CalculateDataGitadora,
Dora: CalculateDataGitadora,
},
bms: {
"7K": CalculateDataBMS7K,
"14K": CalculateDataBMS14K,
},
ddr: {
SP: CalculateDataDDR,
DP: CalculateDataDDR,
},
jubeat: {
Single: CalculateDataJubeat,
},
usc: {
Single: CalculateDataSDVXorUSC,
},
};
// Creates Game-Specific calculatedData for the provided game & playtype.
export function CalculateDataForGamePT<G extends Game>(
game: G,
playtype: Playtypes[G],
chart: AnyChartDocument,
dryScore: DryScore,
// ESD gets specially passed through because it's not part of the DryScore, but
// can be used for statistics anyway.
esd: number | null,
defaultTierlistID: string | undefined,
logger: KtLogger
game: G,
playtype: Playtypes[G],
chart: AnyChartDocument,
dryScore: DryScore,
// ESD gets specially passed through because it's not part of the DryScore, but
// can be used for statistics anyway.
esd: number | null,
defaultTierlistID: string | undefined,
logger: KtLogger
): Promise<ScoreDocument["calculatedData"]> {
const GameRatingFns = CalculatedDataFunctions[game];
const GameRatingFns = CalculatedDataFunctions[game];
if (!HasOwnProperty(GameRatingFns, playtype)) {
logger.error(
`Invalid playtype of ${playtype} given for game ${game} in CalculateDataForGamePT, returning an empty object.`
);
return new Promise((resolve) => resolve({}));
}
if (!HasOwnProperty(GameRatingFns, playtype)) {
logger.error(
`Invalid playtype of ${playtype} given for game ${game} in CalculateDataForGamePT, returning an empty object.`
);
return new Promise((resolve) => resolve({}));
}
// @ts-expect-error standard game->pt stuff.
return GameRatingFns[playtype](dryScore, chart, defaultTierlistID, logger);
// @ts-expect-error standard game->pt stuff.
return GameRatingFns[playtype](dryScore, chart, defaultTierlistID, logger);
}
type CalculatedData<I extends IDStrings> = Required<ScoreDocument<I>["calculatedData"]>;
async function CalculateDataIIDXSP(
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
): Promise<CalculatedData<"iidx:SP">> {
const BPIData = await db["iidx-bpi-data"].findOne({
chartID: chart.chartID,
});
const BPIData = await db["iidx-bpi-data"].findOne({
chartID: chart.chartID,
});
let bpi;
const kPercent = await KaidenPercentile(dryScore, chart);
let bpi;
const kPercent = await KaidenPercentile(dryScore, chart);
if (BPIData) {
bpi = CalculateBPI(
BPIData.kavg,
BPIData.wr,
dryScore.scoreData.score,
(chart as ChartDocument<"iidx:DP" | "iidx:SP">).data.notecount * 2,
BPIData.coef
);
if (BPIData) {
bpi = CalculateBPI(
BPIData.kavg,
BPIData.wr,
dryScore.scoreData.score,
(chart as ChartDocument<"iidx:DP" | "iidx:SP">).data.notecount * 2,
BPIData.coef
);
// kesdc = esd === null ? null : CalculateKESDC(BPIData.kesd, esd); disabled
} else {
bpi = null;
}
// kesdc = esd === null ? null : CalculateKESDC(BPIData.kesd, esd); disabled
} else {
bpi = null;
}
return {
BPI: bpi,
"K%": kPercent,
ktRating: await CalculateKTRating(dryScore, "iidx", "SP", chart, logger, defaultTierlistID),
ktLampRating: await CalculateKTLampRating(dryScore, "iidx", "SP", chart, defaultTierlistID),
};
return {
BPI: bpi,
"K%": kPercent,
ktRating: await CalculateKTRating(dryScore, "iidx", "SP", chart, logger, defaultTierlistID),
ktLampRating: await CalculateKTLampRating(dryScore, "iidx", "SP", chart, defaultTierlistID),
};
}
async function CalculateDataIIDXDP(
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
): Promise<CalculatedData<"iidx:DP">> {
const BPIData = await db["iidx-bpi-data"].findOne({
chartID: chart.chartID,
});
const BPIData = await db["iidx-bpi-data"].findOne({
chartID: chart.chartID,
});
let bpi;
let bpi;
if (BPIData) {
bpi = CalculateBPI(
BPIData.kavg,
BPIData.wr,
dryScore.scoreData.score,
(chart as ChartDocument<"iidx:DP" | "iidx:SP">).data.notecount * 2,
BPIData.coef
);
if (BPIData) {
bpi = CalculateBPI(
BPIData.kavg,
BPIData.wr,
dryScore.scoreData.score,
(chart as ChartDocument<"iidx:DP" | "iidx:SP">).data.notecount * 2,
BPIData.coef
);
// kesdc = esd === null ? null : CalculateKESDC(BPIData.kesd, esd); disabled
} else {
bpi = null;
}
// kesdc = esd === null ? null : CalculateKESDC(BPIData.kesd, esd); disabled
} else {
bpi = null;
}
return {
BPI: bpi,
ktRating: await CalculateKTRating(dryScore, "iidx", "DP", chart, logger, defaultTierlistID),
ktLampRating: await CalculateKTLampRating(dryScore, "iidx", "DP", chart, defaultTierlistID),
};
return {
BPI: bpi,
ktRating: await CalculateKTRating(dryScore, "iidx", "DP", chart, logger, defaultTierlistID),
ktLampRating: await CalculateKTLampRating(dryScore, "iidx", "DP", chart, defaultTierlistID),
};
}
function CalculateDataSDVXorUSC(
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
): CalculatedData<"sdvx:Single" | "usc:Single"> {
const VF6 = CalculateVF6(
dryScore.scoreData.grade as Grades["sdvx:Single"],
dryScore.scoreData.lamp as Lamps["sdvx:Single"],
dryScore.scoreData.percent,
chart.levelNum,
logger
);
const VF6 = CalculateVF6(
dryScore.scoreData.grade as Grades["sdvx:Single"],
dryScore.scoreData.lamp as Lamps["sdvx:Single"],
dryScore.scoreData.percent,
chart.levelNum,
logger
);
return {
VF6,
};
return {
VF6,
};
}
async function CalculateDataMuseca(
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
): Promise<CalculatedData<"museca:Single">> {
return {
ktRating: await CalculateKTRating(
dryScore,
"museca",
"Single",
chart,
logger,
defaultTierlistID
),
};
return {
ktRating: await CalculateKTRating(
dryScore,
"museca",
"Single",
chart,
logger,
defaultTierlistID
),
};
}
function CalculateDataCHUNITHM(
dryScore: DryScore,
chart: AnyChartDocument
dryScore: DryScore,
chart: AnyChartDocument
): CalculatedData<"chunithm:Single"> {
return {
rating: CalculateCHUNITHMRating(dryScore, chart),
};
return {
rating: CalculateCHUNITHMRating(dryScore, chart),
};
}
async function CalculateDataMaimai(
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
): Promise<CalculatedData<"maimai:Single">> {
return {
ktRating: await CalculateKTRating(
dryScore,
"maimai",
"Single",
chart,
logger,
defaultTierlistID
),
};
return {
ktRating: await CalculateKTRating(
dryScore,
"maimai",
"Single",
chart,
logger,
defaultTierlistID
),
};
}
function CalculateDataGitadora(
dryScore: DryScore,
chart: AnyChartDocument
dryScore: DryScore,
chart: AnyChartDocument
): CalculatedData<"gitadora:Gita" | "gitadora:Dora"> {
return {
skill: CalculateGITADORASkill(dryScore, chart),
};
return {
skill: CalculateGITADORASkill(dryScore, chart),
};
}
async function CalculateDataDDR(
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
): Promise<CalculatedData<"ddr:SP" | "ddr:DP">> {
return {
MFCP: CalculateMFCP(dryScore, chart, logger),
ktRating: await CalculateKTRating(
dryScore,
"museca",
"Single",
chart,
logger,
defaultTierlistID
),
};
return {
MFCP: CalculateMFCP(dryScore, chart, logger),
ktRating: await CalculateKTRating(
dryScore,
"museca",
"Single",
chart,
logger,
defaultTierlistID
),
};
}
async function CalculateDataBMS14K(
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
): Promise<CalculatedData<"bms:14K">> {
return {
ktLampRating: await CalculateKTLampRating(dryScore, "bms", "14K", chart, defaultTierlistID),
};
return {
ktLampRating: await CalculateKTLampRating(dryScore, "bms", "14K", chart, defaultTierlistID),
};
}
async function CalculateDataBMS7K(
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
): Promise<CalculatedData<"bms:7K">> {
return {
ktLampRating: await CalculateKTLampRating(dryScore, "bms", "7K", chart, defaultTierlistID),
};
return {
ktLampRating: await CalculateKTLampRating(dryScore, "bms", "7K", chart, defaultTierlistID),
};
}
async function CalculateDataJubeat(
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
dryScore: DryScore,
chart: AnyChartDocument,
defaultTierlistID: string | undefined,
logger: KtLogger
): Promise<CalculatedData<"jubeat:Single">> {
return {
jubility: 0, // @todo #163 Jubeat Jubility
};
return {
jubility: 0, // @todo #163 Jubeat Jubility
};
}
@@ -2,546 +2,546 @@ import t from "tap";
import db from "../../../../external/mongo/db";
import { AnyChartDocument, Difficulties, Lamps, ScoreDocument } from "tachi-common";
import {
CalculateBPI,
CalculateCHUNITHMRating,
CalculateGITADORASkill,
CalculateKESDC,
CalculateMFCP,
KaidenPercentile,
CalculateKTLampRating,
CalculateKTRating,
CalculateVF6,
CalculateBPI,
CalculateCHUNITHMRating,
CalculateGITADORASkill,
CalculateKESDC,
CalculateMFCP,
KaidenPercentile,
CalculateKTLampRating,
CalculateKTRating,
CalculateVF6,
} from "./stats";
import CreateLogCtx from "../../../logger/logger";
import { isApproximately } from "../../../../test-utils/asserts";
import { DryScore } from "../common/types";
import {
GetKTDataJSON,
Testing511SPA,
TestingIIDXSPDryScore,
GetKTDataJSON,
Testing511SPA,
TestingIIDXSPDryScore,
} from "../../../../test-utils/test-data";
import { CloseAllConnections } from "../../../../test-utils/close-connections";
import deepmerge from "deepmerge";
import ResetDBState from "../../../../test-utils/resets";
t.test("#CalculateBPI", (t) => {
t.test("AA BPI tests", (t) => {
// data accurate as of 14/04/2021
const WR_AA = 3650;
const KAVG_AA = 3204;
const MAX_AA = 1834 * 2;
const COEF_AA = 1.25945;
t.test("AA BPI tests", (t) => {
// data accurate as of 14/04/2021
const WR_AA = 3650;
const KAVG_AA = 3204;
const MAX_AA = 1834 * 2;
const COEF_AA = 1.25945;
// AA, the song.
function AA_BPI(yourEx: number) {
return CalculateBPI(KAVG_AA, WR_AA, yourEx, MAX_AA, COEF_AA);
}
// AA, the song.
function AA_BPI(yourEx: number) {
return CalculateBPI(KAVG_AA, WR_AA, yourEx, MAX_AA, COEF_AA);
}
t.equal(AA_BPI(KAVG_AA), 0, "A score of KAVG should be exactly 0BPI");
isApproximately(AA_BPI(3393), 10.02, "A score with 3393 should be approximately 10.02BPI");
isApproximately(AA_BPI(3481), 20.09, "A score with 3481 should be approximately 20.09BPI");
isApproximately(AA_BPI(3535), 30.01, "A score with 3535 should be approximately 30.01BPI");
isApproximately(AA_BPI(3572), 40.18, "A score with 3572 should be approximately 40.18BPI");
isApproximately(AA_BPI(3597), 50.1, "A score with 3597 should be approximately 50.1BPI");
isApproximately(AA_BPI(3615), 60.12, "A score with 3615 should be approximately 60.12BPI");
isApproximately(AA_BPI(3628), 70.11, "A score with 3628 should be approximately 70.11BPI");
isApproximately(AA_BPI(3638), 80.62, "A score with 3638 should be approximately 80.62BPI");
isApproximately(AA_BPI(3645), 90.59, "A score with 3645 should be approximately 90.59BPI");
t.equal(AA_BPI(WR_AA), 100, "A score of WR should be exactly 100BPI");
t.equal(AA_BPI(KAVG_AA), 0, "A score of KAVG should be exactly 0BPI");
isApproximately(AA_BPI(3393), 10.02, "A score with 3393 should be approximately 10.02BPI");
isApproximately(AA_BPI(3481), 20.09, "A score with 3481 should be approximately 20.09BPI");
isApproximately(AA_BPI(3535), 30.01, "A score with 3535 should be approximately 30.01BPI");
isApproximately(AA_BPI(3572), 40.18, "A score with 3572 should be approximately 40.18BPI");
isApproximately(AA_BPI(3597), 50.1, "A score with 3597 should be approximately 50.1BPI");
isApproximately(AA_BPI(3615), 60.12, "A score with 3615 should be approximately 60.12BPI");
isApproximately(AA_BPI(3628), 70.11, "A score with 3628 should be approximately 70.11BPI");
isApproximately(AA_BPI(3638), 80.62, "A score with 3638 should be approximately 80.62BPI");
isApproximately(AA_BPI(3645), 90.59, "A score with 3645 should be approximately 90.59BPI");
t.equal(AA_BPI(WR_AA), 100, "A score of WR should be exactly 100BPI");
isApproximately(AA_BPI(3041), -5, "A score with 3628 should be approximately -5BPI");
isApproximately(AA_BPI(2886), -9.99, "A score with 3638 should be approximately -9.99BPI");
isApproximately(AA_BPI(3041), -5, "A score with 3628 should be approximately -5BPI");
isApproximately(AA_BPI(2886), -9.99, "A score with 3638 should be approximately -9.99BPI");
t.equal(AA_BPI(0), -15, "Excessively bad (vs kavg) scores should cap at -15BPI");
t.equal(AA_BPI(0), -15, "Excessively bad (vs kavg) scores should cap at -15BPI");
isApproximately(AA_BPI(MAX_AA), 244.56, "A score of MAX should be approximately 244.56BPI");
isApproximately(AA_BPI(MAX_AA), 244.56, "A score of MAX should be approximately 244.56BPI");
t.end();
});
t.end();
});
// This song has both no co-efficient and is an sp11, so it serves
// as another interesting test.
t.test("Afterimage d'automne BPI tests", (t) => {
// data accurate as of 14/04/2021
const WR_AFT = 2891;
const KAVG_AFT = 2497;
const MAX_AFT = 1480 * 2;
const COEF_AFT = null;
// This song has both no co-efficient and is an sp11, so it serves
// as another interesting test.
t.test("Afterimage d'automne BPI tests", (t) => {
// data accurate as of 14/04/2021
const WR_AFT = 2891;
const KAVG_AFT = 2497;
const MAX_AFT = 1480 * 2;
const COEF_AFT = null;
function AFT_BPI(yourEx: number) {
return CalculateBPI(KAVG_AFT, WR_AFT, yourEx, MAX_AFT, COEF_AFT);
}
function AFT_BPI(yourEx: number) {
return CalculateBPI(KAVG_AFT, WR_AFT, yourEx, MAX_AFT, COEF_AFT);
}
t.equal(AFT_BPI(KAVG_AFT), 0, "A score of KAVG should be exactly 0BPI");
isApproximately(AFT_BPI(2606), 10, "A score with 2606 should be approximately 10BPI");
isApproximately(AFT_BPI(2675), 20.07, "A score with 2675 should be approximately 20.07BPI");
isApproximately(AFT_BPI(2727), 30.18, "A score with 2727 should be approximately 30.18BPI");
isApproximately(AFT_BPI(2767), 40.12, "A score with 2767 should be approximately 40.12BPI");
isApproximately(AFT_BPI(2799), 50.06, "A score with 2799 should be approximately 50.06BPI");
isApproximately(AFT_BPI(2826), 60.43, "A score with 2826 should be approximately 60.43BPI");
isApproximately(AFT_BPI(2847), 70.3, "A score with 2847 should be approximately 70.3BPI");
isApproximately(AFT_BPI(2865), 80.57, "A score with 2865 should be approximately 80.57BPI");
isApproximately(AFT_BPI(2879), 90.18, "A score with 2879 should be approximately 90.18BPI");
t.equal(AFT_BPI(WR_AFT), 100, "A score of WR should be exactly 100BPI");
t.equal(AFT_BPI(KAVG_AFT), 0, "A score of KAVG should be exactly 0BPI");
isApproximately(AFT_BPI(2606), 10, "A score with 2606 should be approximately 10BPI");
isApproximately(AFT_BPI(2675), 20.07, "A score with 2675 should be approximately 20.07BPI");
isApproximately(AFT_BPI(2727), 30.18, "A score with 2727 should be approximately 30.18BPI");
isApproximately(AFT_BPI(2767), 40.12, "A score with 2767 should be approximately 40.12BPI");
isApproximately(AFT_BPI(2799), 50.06, "A score with 2799 should be approximately 50.06BPI");
isApproximately(AFT_BPI(2826), 60.43, "A score with 2826 should be approximately 60.43BPI");
isApproximately(AFT_BPI(2847), 70.3, "A score with 2847 should be approximately 70.3BPI");
isApproximately(AFT_BPI(2865), 80.57, "A score with 2865 should be approximately 80.57BPI");
isApproximately(AFT_BPI(2879), 90.18, "A score with 2879 should be approximately 90.18BPI");
t.equal(AFT_BPI(WR_AFT), 100, "A score of WR should be exactly 100BPI");
isApproximately(AFT_BPI(2423), -4.99, "A score with 2423 should be approximately -4.99BPI");
isApproximately(
AFT_BPI(2354),
-10.04,
"A score with 2354 should be approximately -10.04BPI"
);
isApproximately(AFT_BPI(2423), -4.99, "A score with 2423 should be approximately -4.99BPI");
isApproximately(
AFT_BPI(2354),
-10.04,
"A score with 2354 should be approximately -10.04BPI"
);
t.equal(AFT_BPI(0), -15, "Excessively bad (vs kavg) scores should cap at -15BPI");
t.equal(AFT_BPI(0), -15, "Excessively bad (vs kavg) scores should cap at -15BPI");
isApproximately(
AFT_BPI(MAX_AFT),
431.57,
"A score of MAX should be approximately 431.57BPI"
);
isApproximately(
AFT_BPI(MAX_AFT),
431.57,
"A score of MAX should be approximately 431.57BPI"
);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.test("#CalculateGITADORARating", (t) => {
function TestGitadoraRating(percent: number, levelNum: number) {
return CalculateGITADORASkill(
{ scoreData: { percent } } as DryScore,
{ levelNum } as AnyChartDocument
);
}
function TestGitadoraRating(percent: number, levelNum: number) {
return CalculateGITADORASkill(
{ scoreData: { percent } } as DryScore,
{ levelNum } as AnyChartDocument
);
}
t.equal(
TestGitadoraRating(89.48, 3.4),
60.84,
"Test GITADORA Rating function aligns with game (1)"
);
t.equal(
TestGitadoraRating(89.48, 3.4),
60.84,
"Test GITADORA Rating function aligns with game (1)"
);
t.equal(
TestGitadoraRating(70.76, 5.8),
82.08,
"Test GITADORA Rating function aligns with game (2)"
);
t.equal(
TestGitadoraRating(70.76, 5.8),
82.08,
"Test GITADORA Rating function aligns with game (2)"
);
t.end();
t.end();
});
// this metric is ground to be removed, possibly.
t.test("#CalculateKESDC", (t) => {
t.equal(CalculateKESDC(10, 4), 37.18460876390943, "Should perform ESDC on two numbers.");
t.equal(CalculateKESDC(null, 4), null, "Should return null if kaidenESD is null.");
t.equal(CalculateKESDC(10, 4), 37.18460876390943, "Should perform ESDC on two numbers.");
t.equal(CalculateKESDC(null, 4), null, "Should return null if kaidenESD is null.");
t.end();
t.end();
});
const logger = CreateLogCtx(__filename);
t.test("#CalculateMFCP", (t) => {
function TestMFCP(
lamp: Lamps["ddr:SP" | "ddr:DP"],
levelNum: number,
difficulty: Difficulties["ddr:DP" | "ddr:SP"]
) {
return CalculateMFCP(
{
scoreData: {
lamp,
},
} as ScoreDocument,
{
difficulty,
levelNum,
} as AnyChartDocument,
logger
);
}
function TestMFCP(
lamp: Lamps["ddr:SP" | "ddr:DP"],
levelNum: number,
difficulty: Difficulties["ddr:DP" | "ddr:SP"]
) {
return CalculateMFCP(
{
scoreData: {
lamp,
},
} as ScoreDocument,
{
difficulty,
levelNum,
} as AnyChartDocument,
logger
);
}
t.equal(TestMFCP("FAILED", 10, "EXPERT"), null, "Should return null for non-mfcs");
t.equal(TestMFCP("FAILED", 10, "EXPERT"), null, "Should return null for non-mfcs");
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 10, "BASIC"),
null,
"Should reject charts on BASIC difficulty."
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 10, "BASIC"),
null,
"Should reject charts on BASIC difficulty."
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 10, "BEGINNER"),
null,
"Should reject charts on BEGINNER difficulty."
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 10, "BEGINNER"),
null,
"Should reject charts on BEGINNER difficulty."
);
t.test("Should return null for charts with level less than 8", (t) => {
for (let i = 1; i <= 7; i++) {
t.equal(
TestMFCP("MARVELOUS FULL COMBO", i, "EXPERT"),
null,
`Should return null for charts with level ${i}`
);
}
t.test("Should return null for charts with level less than 8", (t) => {
for (let i = 1; i <= 7; i++) {
t.equal(
TestMFCP("MARVELOUS FULL COMBO", i, "EXPERT"),
null,
`Should return null for charts with level ${i}`
);
}
t.end();
});
t.end();
});
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 8, "EXPERT"),
1,
"Should return 1 for charts with level 8"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 9, "EXPERT"),
1,
"Should return 1 for charts with level 9"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 10, "EXPERT"),
1,
"Should return 1 for charts with level 10"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 8, "EXPERT"),
1,
"Should return 1 for charts with level 8"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 9, "EXPERT"),
1,
"Should return 1 for charts with level 9"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 10, "EXPERT"),
1,
"Should return 1 for charts with level 10"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 11, "EXPERT"),
2,
"Should return 2 for charts with level 11"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 12, "EXPERT"),
2,
"Should return 2 for charts with level 12"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 11, "EXPERT"),
2,
"Should return 2 for charts with level 11"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 12, "EXPERT"),
2,
"Should return 2 for charts with level 12"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 13, "EXPERT"),
4,
"Should return 4 for charts with level 13"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 13, "EXPERT"),
4,
"Should return 4 for charts with level 13"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 14, "EXPERT"),
8,
"Should return 8 for charts with level 14"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 14, "EXPERT"),
8,
"Should return 8 for charts with level 14"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 15, "EXPERT"),
15,
"Should return 15 for charts with level 15"
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", 15, "EXPERT"),
15,
"Should return 15 for charts with level 15"
);
t.test("Should return 25 for charts with level 16-20", (t) => {
for (let i = 16; i <= 20; i++) {
t.equal(
TestMFCP("MARVELOUS FULL COMBO", i, "EXPERT"),
25,
`Should return 25 for charts with level ${i}`
);
}
t.end();
});
t.test("Should return 25 for charts with level 16-20", (t) => {
for (let i = 16; i <= 20; i++) {
t.equal(
TestMFCP("MARVELOUS FULL COMBO", i, "EXPERT"),
25,
`Should return 25 for charts with level ${i}`
);
}
t.end();
});
t.equal(
TestMFCP("MARVELOUS FULL COMBO", NaN, "EXPERT"),
null,
"Invalid level triggers failsafe."
);
t.equal(
TestMFCP("MARVELOUS FULL COMBO", NaN, "EXPERT"),
null,
"Invalid level triggers failsafe."
);
t.end();
t.end();
});
const bbkk = GetKTDataJSON("./tachi/chunithm-bbkk-chart.json");
// unit testing a mathematical function is a square-round-hole problem.
t.test("#CalculateCHUNITHMRating", (t) => {
// cutoffs
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 1_010_000 } } as DryScore, bbkk), 5.0);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 1_005_000 } } as DryScore, bbkk), 4.5);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 1_000_000 } } as DryScore, bbkk), 4);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 975_000 } } as DryScore, bbkk), 3);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 925_000 } } as DryScore, bbkk), 0);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 900_000 } } as DryScore, bbkk), 0);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 800_000 } } as DryScore, bbkk), 0);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 0 } } as DryScore, bbkk), 0);
// cutoffs
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 1_010_000 } } as DryScore, bbkk), 5.0);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 1_005_000 } } as DryScore, bbkk), 4.5);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 1_000_000 } } as DryScore, bbkk), 4);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 975_000 } } as DryScore, bbkk), 3);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 925_000 } } as DryScore, bbkk), 0);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 900_000 } } as DryScore, bbkk), 0);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 800_000 } } as DryScore, bbkk), 0);
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 0 } } as DryScore, bbkk), 0);
// inbetweens
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 987_000 } } as DryScore, bbkk), 3.48);
// inbetweens
t.equal(CalculateCHUNITHMRating({ scoreData: { score: 987_000 } } as DryScore, bbkk), 3.48);
t.end();
t.end();
});
// Random assertions plucked from bemaniwiki.
t.test("#CalculateVF6", (t) => {
t.equal(CalculateVF6("S", "CLEAR", 99, 16, logger), 0.332);
t.equal(CalculateVF6("S", "CLEAR", 99, 16, logger), 0.332);
t.equal(CalculateVF6("AAA+", "ULTIMATE CHAIN", 98, 17, logger), 0.356);
t.equal(CalculateVF6("AAA+", "ULTIMATE CHAIN", 98, 17, logger), 0.356);
t.equal(CalculateVF6("S", "PERFECT ULTIMATE CHAIN", 100, 16, logger), 0.369);
t.equal(CalculateVF6("S", "PERFECT ULTIMATE CHAIN", 100, 17, logger), 0.392);
t.equal(CalculateVF6("S", "PERFECT ULTIMATE CHAIN", 100, 18, logger), 0.415);
t.equal(CalculateVF6("S", "PERFECT ULTIMATE CHAIN", 100, 19, logger), 0.438);
t.equal(CalculateVF6("S", "PERFECT ULTIMATE CHAIN", 100, 20, logger), 0.462);
t.equal(CalculateVF6("S", "PERFECT ULTIMATE CHAIN", 100, 16, logger), 0.369);
t.equal(CalculateVF6("S", "PERFECT ULTIMATE CHAIN", 100, 17, logger), 0.392);
t.equal(CalculateVF6("S", "PERFECT ULTIMATE CHAIN", 100, 18, logger), 0.415);
t.equal(CalculateVF6("S", "PERFECT ULTIMATE CHAIN", 100, 19, logger), 0.438);
t.equal(CalculateVF6("S", "PERFECT ULTIMATE CHAIN", 100, 20, logger), 0.462);
t.end();
t.end();
});
t.test("#KaidenPercentile", async (t) => {
await db["iidx-eam-scores"].insert(
[100, 200, 300, 400, 1000, 2000, 3000, 4000].map((e) => ({
chartID: Testing511SPA.chartID,
score: e,
lamp: "EASY CLEAR",
ranking: 0, // ignore
}))
);
await db["iidx-eam-scores"].insert(
[100, 200, 300, 400, 1000, 2000, 3000, 4000].map((e) => ({
chartID: Testing511SPA.chartID,
score: e,
lamp: "EASY CLEAR",
ranking: 0, // ignore
}))
);
const res = await KaidenPercentile(TestingIIDXSPDryScore, Testing511SPA);
t.equal(res, 50);
const res = await KaidenPercentile(TestingIIDXSPDryScore, Testing511SPA);
t.equal(res, 50);
await db["iidx-eam-scores"].remove({});
const res2 = await KaidenPercentile(TestingIIDXSPDryScore, Testing511SPA);
await db["iidx-eam-scores"].remove({});
const res2 = await KaidenPercentile(TestingIIDXSPDryScore, Testing511SPA);
t.equal(res2, null);
t.equal(res2, null);
t.end();
t.end();
});
t.test("#CalculateRating", (t) => {
t.test("Should call the success calculator if percent > pivotPercent", async (t) => {
const r = await CalculateKTRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { percent: 80 } }),
"iidx",
"SP",
Testing511SPA,
logger
);
t.test("Should call the success calculator if percent > pivotPercent", async (t) => {
const r = await CalculateKTRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { percent: 80 } }),
"iidx",
"SP",
Testing511SPA,
logger
);
t.ok(r > 10, "Should return rating greater than the levelNum of the chart.");
t.ok(r > 10, "Should return rating greater than the levelNum of the chart.");
t.end();
});
t.end();
});
t.test("Should call the fail calculator if percent > pivotPercent", async (t) => {
const r = await CalculateKTRating(
TestingIIDXSPDryScore,
"iidx",
"SP",
Testing511SPA,
logger
);
t.test("Should call the fail calculator if percent > pivotPercent", async (t) => {
const r = await CalculateKTRating(
TestingIIDXSPDryScore,
"iidx",
"SP",
Testing511SPA,
logger
);
t.ok(r < 10, "Should return rating less than the levelNum of the chart.");
t.ok(r < 10, "Should return rating less than the levelNum of the chart.");
t.end();
});
t.end();
});
t.test("Should call levelNum if percent === pivotPercent", async (t) => {
const r = await CalculateKTRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { percent: 77.7777 } }),
"iidx",
"SP",
Testing511SPA,
logger
);
t.test("Should call levelNum if percent === pivotPercent", async (t) => {
const r = await CalculateKTRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { percent: 77.7777 } }),
"iidx",
"SP",
Testing511SPA,
logger
);
t.equal(
// hack for approximate tests
parseFloat(r.toFixed(2)),
10,
"Should return rating exactly that of the levelNum of the chart."
);
t.equal(
// hack for approximate tests
parseFloat(r.toFixed(2)),
10,
"Should return rating exactly that of the levelNum of the chart."
);
t.end();
});
t.end();
});
t.test(
"Should trigger safety if completely invalid percent somehow gets through",
async (t) => {
let r = await CalculateKTRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { percent: 1000000000 } }),
"iidx",
"SP",
Testing511SPA,
logger
);
t.test(
"Should trigger safety if completely invalid percent somehow gets through",
async (t) => {
let r = await CalculateKTRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { percent: 1000000000 } }),
"iidx",
"SP",
Testing511SPA,
logger
);
t.equal(r, 0, "Should safely return 0 and log a warning.");
t.equal(r, 0, "Should safely return 0 and log a warning.");
r = await CalculateKTRating(
// not high enough to be non-finite but high enough to be > 1000
deepmerge(TestingIIDXSPDryScore, { scoreData: { percent: 200 } }),
"iidx",
"SP",
Testing511SPA,
logger
);
r = await CalculateKTRating(
// not high enough to be non-finite but high enough to be > 1000
deepmerge(TestingIIDXSPDryScore, { scoreData: { percent: 200 } }),
"iidx",
"SP",
Testing511SPA,
logger
);
t.equal(r, 0, "Should safely return 0 and log a warning.");
t.equal(r, 0, "Should safely return 0 and log a warning.");
t.end();
}
);
t.end();
}
);
t.end();
t.end();
});
t.test("#CalculateLampRating", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
const defaultTierlist = {
createdAt: 1620150338858,
tierlistID: "ee9b756e50cff8282091102257b01f423ef855f2",
createdBy: 1,
description: "The official Kamaitachi Tierlist for beatmania IIDX (SP).",
game: "iidx",
playtype: "SP",
isDefault: true,
name: "Kamaitachi IIDX SP Official",
lastUpdated: 1620150338858,
permissions: {
anyPlayer: {
edit: 0,
submit: 1,
vote: 1,
},
},
config: {
autoHumanise: false,
flags: ["Individual Difference"],
requireState: "clear",
},
};
const defaultTierlist = {
createdAt: 1620150338858,
tierlistID: "ee9b756e50cff8282091102257b01f423ef855f2",
createdBy: 1,
description: "The official Kamaitachi Tierlist for beatmania IIDX (SP).",
game: "iidx",
playtype: "SP",
isDefault: true,
name: "Kamaitachi IIDX SP Official",
lastUpdated: 1620150338858,
permissions: {
anyPlayer: {
edit: 0,
submit: 1,
vote: 1,
},
},
config: {
autoHumanise: false,
flags: ["Individual Difference"],
requireState: "clear",
},
};
t.test("TierlistData", async (t) => {
const lampRating = await CalculateKTLampRating(
TestingIIDXSPDryScore,
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
t.test("TierlistData", async (t) => {
const lampRating = await CalculateKTLampRating(
TestingIIDXSPDryScore,
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
t.equal(lampRating, 10, "Should equal the levelNum of the chart.");
t.equal(lampRating, 10, "Should equal the levelNum of the chart.");
const lampRatingFail = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "FAILED" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
const lampRatingFail = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "FAILED" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
t.equal(lampRatingFail, 0, "Should equal 0, if the score is not a clear.");
t.equal(lampRatingFail, 0, "Should equal 0, if the score is not a clear.");
const lampRatingHC = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "HARD CLEAR" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
const lampRatingHC = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "HARD CLEAR" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
t.equal(lampRatingHC, 10.6, "Should equal the tierlist value for HC on this chart.");
t.equal(lampRatingHC, 10.6, "Should equal the tierlist value for HC on this chart.");
const lampRatingEXHC = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "EX HARD CLEAR" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
const lampRatingEXHC = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "EX HARD CLEAR" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
// deliberate - there is no EXHC data here
t.equal(lampRatingEXHC, 10.6, "Should equal the tierlist value for HC on this chart.");
// deliberate - there is no EXHC data here
t.equal(lampRatingEXHC, 10.6, "Should equal the tierlist value for HC on this chart.");
t.end();
});
t.end();
});
t.test("TierlistData edge cases", async (t) => {
// mock document that implies 5.1.1. SPA is worth 11.9 to NC and 10.6 to HC.
await db["tierlist-data"].insert({
chartID: Testing511SPA.chartID,
type: "lamp",
key: "CLEAR",
tierlistDataID: "asdf",
tierlistID: defaultTierlist!.tierlistID,
data: {
flags: {},
humanised: "a",
value: 11.9,
},
});
t.test("TierlistData edge cases", async (t) => {
// mock document that implies 5.1.1. SPA is worth 11.9 to NC and 10.6 to HC.
await db["tierlist-data"].insert({
chartID: Testing511SPA.chartID,
type: "lamp",
key: "CLEAR",
tierlistDataID: "asdf",
tierlistID: defaultTierlist!.tierlistID,
data: {
flags: {},
humanised: "a",
value: 11.9,
},
});
const lampRating = await CalculateKTLampRating(
TestingIIDXSPDryScore,
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
const lampRating = await CalculateKTLampRating(
TestingIIDXSPDryScore,
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
t.equal(lampRating, 11.9, "Should equal the NC value of the chart.");
t.equal(lampRating, 11.9, "Should equal the NC value of the chart.");
const lampRatingFail = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "FAILED" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
const lampRatingFail = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "FAILED" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
t.equal(lampRatingFail, 0, "Should equal 0, if the score is not a clear.");
t.equal(lampRatingFail, 0, "Should equal 0, if the score is not a clear.");
const lampRatingHC = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "HARD CLEAR" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
const lampRatingHC = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "HARD CLEAR" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
t.equal(
lampRatingHC,
11.9,
"Should equal the tierlist value for NC, as it is higher than HC on this chart."
);
t.equal(
lampRatingHC,
11.9,
"Should equal the tierlist value for NC, as it is higher than HC on this chart."
);
const lampRatingEXHC = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "EX HARD CLEAR" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
const lampRatingEXHC = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "EX HARD CLEAR" } }),
"iidx",
"SP",
Testing511SPA,
defaultTierlist!.tierlistID
);
t.equal(lampRatingEXHC, 11.9, "Should equal the tierlist value for NC on this chart.");
t.equal(lampRatingEXHC, 11.9, "Should equal the tierlist value for NC on this chart.");
t.end();
});
t.end();
});
t.test("No TierlistData", async (t) => {
await db["tierlist-data"].remove({});
const lampRating = await CalculateKTLampRating(
TestingIIDXSPDryScore,
"iidx",
"SP",
Testing511SPA
);
t.test("No TierlistData", async (t) => {
await db["tierlist-data"].remove({});
const lampRating = await CalculateKTLampRating(
TestingIIDXSPDryScore,
"iidx",
"SP",
Testing511SPA
);
t.equal(lampRating, 10, "Should equal the levelNum of the chart.");
t.equal(lampRating, 10, "Should equal the levelNum of the chart.");
const lampRatingFail = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "FAILED" } }),
"iidx",
"SP",
Testing511SPA
);
const lampRatingFail = await CalculateKTLampRating(
deepmerge(TestingIIDXSPDryScore, { scoreData: { lamp: "FAILED" } }),
"iidx",
"SP",
Testing511SPA
);
t.equal(lampRatingFail, 0, "Should equal 0, if the score is not a clear.");
t.equal(lampRatingFail, 0, "Should equal 0, if the score is not a clear.");
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -9,37 +9,37 @@ import { lamps, clearLamp, ratingParameters } from "tachi-common/js/config";
* Calculates the in-game CHUNITHM rating for a score.
*/
export function CalculateCHUNITHMRating(dryScore: DryScore, chartData: AnyChartDocument) {
const score = dryScore.scoreData.score;
const levelBase = chartData.levelNum * 100;
const score = dryScore.scoreData.score;
const levelBase = chartData.levelNum * 100;
let val = 0;
let val = 0;
if (score >= 1_007_500) {
val = levelBase + 200;
} else if (score >= 1_005_000) {
val = levelBase + 150 + ((score - 1_005_000) * 10) / 500;
} else if (score >= 1_000_000) {
val = levelBase + 100 + ((score - 1_000_000) * 5) / 100;
} else if (score >= 975_000) {
val = levelBase + ((score - 975_000) * 2) / 500;
} else if (score >= 925_000) {
val = levelBase - 300 + ((score - 925_000) * 3) / 500;
} else if (score >= 900_000) {
val = levelBase - 500 + ((score - 900_000) * 4) / 500;
} else if (score >= 800_000) {
val = (levelBase - 500) / 2 + ((score - 800_000) * ((levelBase - 500) / 2)) / 100_000;
}
if (score >= 1_007_500) {
val = levelBase + 200;
} else if (score >= 1_005_000) {
val = levelBase + 150 + ((score - 1_005_000) * 10) / 500;
} else if (score >= 1_000_000) {
val = levelBase + 100 + ((score - 1_000_000) * 5) / 100;
} else if (score >= 975_000) {
val = levelBase + ((score - 975_000) * 2) / 500;
} else if (score >= 925_000) {
val = levelBase - 300 + ((score - 925_000) * 3) / 500;
} else if (score >= 900_000) {
val = levelBase - 500 + ((score - 900_000) * 4) / 500;
} else if (score >= 800_000) {
val = (levelBase - 500) / 2 + ((score - 800_000) * ((levelBase - 500) / 2)) / 100_000;
}
return Math.max(Math.floor(val) / 100, 0);
return Math.max(Math.floor(val) / 100, 0);
}
/**
* Calculates the in-game GITADORA rating for a score.
*/
export function CalculateGITADORASkill(dryScore: DryScore, chartData: AnyChartDocument) {
const trueRating = (dryScore.scoreData.percent / 100) * chartData.levelNum * 20;
const flooredRating = Math.floor(trueRating * 100) / 100;
return flooredRating;
const trueRating = (dryScore.scoreData.percent / 100) * chartData.levelNum * 20;
const flooredRating = Math.floor(trueRating * 100) / 100;
return flooredRating;
}
/**
@@ -47,7 +47,7 @@ export function CalculateGITADORASkill(dryScore: DryScore, chartData: AnyChartDo
* @returns
*/
function BPIPikaGreatFn(score: integer, max: integer) {
return score === max ? max * 0.8 : 1 + (score / max - 0.5) / (1 - score / max);
return score === max ? max * 0.8 : 1 + (score / max - 0.5) / (1 - score / max);
}
/**
@@ -66,35 +66,35 @@ function BPIPikaGreatFn(score: integer, max: integer) {
* returns can be above 100.
*/
export function CalculateBPI(
kaidenEx: integer,
wrEx: integer,
yourEx: integer,
max: integer,
pc: number | null
kaidenEx: integer,
wrEx: integer,
yourEx: integer,
max: integer,
pc: number | null
) {
const powCoef = pc ?? 1.175;
const yourPGF = BPIPikaGreatFn(yourEx, max);
const kaidenPGF = BPIPikaGreatFn(kaidenEx, max);
const wrPGF = BPIPikaGreatFn(wrEx, max);
const powCoef = pc ?? 1.175;
const yourPGF = BPIPikaGreatFn(yourEx, max);
const kaidenPGF = BPIPikaGreatFn(kaidenEx, max);
const wrPGF = BPIPikaGreatFn(wrEx, max);
// no idea what these var names are
const _s_ = yourPGF / kaidenPGF,
_z_ = wrPGF / kaidenPGF;
// no idea what these var names are
const _s_ = yourPGF / kaidenPGF,
_z_ = wrPGF / kaidenPGF;
const isBetterThanKavg = yourEx >= kaidenEx;
const isBetterThanKavg = yourEx >= kaidenEx;
// this line of code isn't mine, and that's why it's *really* bad here.
return Math.max(
-15,
Math.round(
(isBetterThanKavg ? 100 : -100) *
Math.pow(
(isBetterThanKavg ? Math.log(_s_) : -Math.log(_s_)) / Math.log(_z_),
powCoef
) *
100
) / 100
);
// this line of code isn't mine, and that's why it's *really* bad here.
return Math.max(
-15,
Math.round(
(isBetterThanKavg ? 100 : -100) *
Math.pow(
(isBetterThanKavg ? Math.log(_s_) : -Math.log(_s_)) / Math.log(_z_),
powCoef
) *
100
) / 100
);
}
/**
@@ -102,20 +102,20 @@ export function CalculateBPI(
* @returns Null, if this chart has no kaidens, a percent between 0 and 100, if there are.
*/
export async function KaidenPercentile(scoreObj: DryScore, chartData: AnyChartDocument) {
const scoreCount = await db["iidx-eam-scores"].count({
chartID: chartData.chartID,
});
const scoreCount = await db["iidx-eam-scores"].count({
chartID: chartData.chartID,
});
if (!scoreCount) {
return null;
}
if (!scoreCount) {
return null;
}
const worseScores = await db["iidx-eam-scores"].count({
chartID: chartData.chartID,
score: { $lt: scoreObj.scoreData.score },
});
const worseScores = await db["iidx-eam-scores"].count({
chartID: chartData.chartID,
score: { $lt: scoreObj.scoreData.score },
});
return (100 * worseScores) / scoreCount;
return (100 * worseScores) / scoreCount;
}
/**
@@ -123,10 +123,10 @@ export async function KaidenPercentile(scoreObj: DryScore, chartData: AnyChartDo
* For those who know what ESDC is (me), this is just ESDC(kesd, your esd).
*/
export function CalculateKESDC(kaidenESD: number | null, yourESD: number) {
if (!kaidenESD) {
return null;
}
return ESDCore.ESDCompare(kaidenESD, yourESD);
if (!kaidenESD) {
return null;
}
return ESDCore.ESDCompare(kaidenESD, yourESD);
}
/**
@@ -136,105 +136,105 @@ export function CalculateKESDC(kaidenESD: number | null, yourESD: number) {
* @returns Null if this score was not eligible, a number otherwise.
*/
export function CalculateMFCP(dryScore: DryScore, chartData: AnyChartDocument, logger: KtLogger) {
if (dryScore.scoreData.lamp !== "MARVELOUS FULL COMBO") {
return null;
}
if (dryScore.scoreData.lamp !== "MARVELOUS FULL COMBO") {
return null;
}
// Beginner and BASIC scores are explicitly excluded.
if (chartData.difficulty === "BEGINNER" || chartData.difficulty === "BASIC") {
return null;
}
// Beginner and BASIC scores are explicitly excluded.
if (chartData.difficulty === "BEGINNER" || chartData.difficulty === "BASIC") {
return null;
}
if (chartData.levelNum < 8) {
return null;
} else if (chartData.levelNum <= 10) {
return 1;
} else if (chartData.levelNum <= 12) {
return 2;
} else if (chartData.levelNum === 13) {
return 4;
} else if (chartData.levelNum === 14) {
return 8;
} else if (chartData.levelNum === 15) {
return 15;
} else if (chartData.levelNum >= 16) {
return 25;
}
if (chartData.levelNum < 8) {
return null;
} else if (chartData.levelNum <= 10) {
return 1;
} else if (chartData.levelNum <= 12) {
return 2;
} else if (chartData.levelNum === 13) {
return 4;
} else if (chartData.levelNum === 14) {
return 8;
} else if (chartData.levelNum === 15) {
return 15;
} else if (chartData.levelNum >= 16) {
return 25;
}
logger.warn(
`Invalid levelNum passed to MFCP ${chartData.levelNum}. ChartID ${chartData.chartID}.`
);
logger.warn(
`Invalid levelNum passed to MFCP ${chartData.levelNum}. ChartID ${chartData.chartID}.`
);
// failsafe
return null;
// failsafe
return null;
}
const VF4GradeCoefficients = {
S: 1.0,
"AAA+": 0.99,
AAA: 0.98,
"AA+": 0.97,
AA: 0.96,
"A+": 0.95,
A: 0.94,
B: 0.93,
C: 0.92,
D: 0.91,
S: 1.0,
"AAA+": 0.99,
AAA: 0.98,
"AA+": 0.97,
AA: 0.96,
"A+": 0.95,
A: 0.94,
B: 0.93,
C: 0.92,
D: 0.91,
};
const VF5GradeCoefficients = {
S: 1.05,
"AAA+": 1.02,
AAA: 1.0,
"AA+": 0.97,
AA: 0.94,
"A+": 0.91, // everything below this point (incl. this) is marked with a (?) in bemaniwiki.
A: 0.88,
B: 0.85,
C: 0.82,
D: 0.8,
S: 1.05,
"AAA+": 1.02,
AAA: 1.0,
"AA+": 0.97,
AA: 0.94,
"A+": 0.91, // everything below this point (incl. this) is marked with a (?) in bemaniwiki.
A: 0.88,
B: 0.85,
C: 0.82,
D: 0.8,
};
const VF5LampCoefficients = {
"PERFECT ULTIMATE CHAIN": 1.1,
"ULTIMATE CHAIN": 1.05,
"EXCESSIVE CLEAR": 1.02,
CLEAR: 1.0,
FAILED: 0.5,
"PERFECT ULTIMATE CHAIN": 1.1,
"ULTIMATE CHAIN": 1.05,
"EXCESSIVE CLEAR": 1.02,
CLEAR: 1.0,
FAILED: 0.5,
};
function FloorToNDP(number: number, dp: integer) {
const mul = 10 ** dp;
return Math.floor(number * mul) / mul;
const mul = 10 ** dp;
return Math.floor(number * mul) / mul;
}
export function CalculateVF6(
grade: Grades["sdvx:Single"],
lamp: Lamps["sdvx:Single"],
per: number,
levelNum: number,
logger: KtLogger
grade: Grades["sdvx:Single"],
lamp: Lamps["sdvx:Single"],
per: number,
levelNum: number,
logger: KtLogger
) {
const gradeCoefficient = VF5GradeCoefficients[grade];
const lampCoefficient = VF5LampCoefficients[lamp];
const gradeCoefficient = VF5GradeCoefficients[grade];
const lampCoefficient = VF5LampCoefficients[lamp];
if (!lampCoefficient) {
logger.warn(`Invalid lamp of ${lamp} passed to CalculateVF5. Returning null.`);
return null;
}
if (!gradeCoefficient) {
logger.warn(`Invalid grade of ${grade} passed to CalculateVF5. Returning null.`);
return null;
}
if (!lampCoefficient) {
logger.warn(`Invalid lamp of ${lamp} passed to CalculateVF5. Returning null.`);
return null;
}
if (!gradeCoefficient) {
logger.warn(`Invalid grade of ${grade} passed to CalculateVF5. Returning null.`);
return null;
}
const percent = per / 100;
if (!levelNum || !percent) {
return 0;
}
const percent = per / 100;
if (!levelNum || !percent) {
return 0;
}
const realVF6 = (levelNum * 2 * percent * gradeCoefficient * lampCoefficient) / 100;
const realVF6 = (levelNum * 2 * percent * gradeCoefficient * lampCoefficient) / 100;
return FloorToNDP(realVF6, 3);
return FloorToNDP(realVF6, 3);
}
// function CalculateJubility(
@@ -246,151 +246,151 @@ export function CalculateVF6(
// }
interface RatingParameters {
failHarshnessMultiplier: number;
pivotPercent: number;
clearExpMultiplier: number;
failHarshnessMultiplier: number;
pivotPercent: number;
clearExpMultiplier: number;
}
// Generic Rating Calc that is guaranteed to work for everything. This is unspecialised, and not great.
function KTRatingCalcV1(
percent: number,
levelNum: number,
parameters: RatingParameters,
logger: KtLogger
percent: number,
levelNum: number,
parameters: RatingParameters,
logger: KtLogger
) {
const percentDiv100 = percent / 100;
const percentDiv100 = percent / 100;
if (percentDiv100 < parameters.pivotPercent) {
return RatingCalcV0Fail(percentDiv100, levelNum, parameters);
}
if (percentDiv100 < parameters.pivotPercent) {
return RatingCalcV0Fail(percentDiv100, levelNum, parameters);
}
return RatingCalcV1Clear(percentDiv100, levelNum, parameters, logger);
return RatingCalcV1Clear(percentDiv100, levelNum, parameters, logger);
}
function RatingCalcV1Clear(
percentDiv100: number,
levelNum: number,
parameters: RatingParameters,
logger: KtLogger
percentDiv100: number,
levelNum: number,
parameters: RatingParameters,
logger: KtLogger
) {
// https://www.desmos.com/calculator/hn7uxjmjkc
// https://www.desmos.com/calculator/hn7uxjmjkc
const rating =
Math.cosh(
parameters.clearExpMultiplier * levelNum * (percentDiv100 - parameters.pivotPercent)
) +
(levelNum - 1);
const rating =
Math.cosh(
parameters.clearExpMultiplier * levelNum * (percentDiv100 - parameters.pivotPercent)
) +
(levelNum - 1);
// checks for Infinity or NaN. I'm not sure how this would happen, but it's a failsafe.
if (!Number.isFinite(rating)) {
logger.warn(
`Percent: ${percentDiv100}, Level: ${levelNum} resulted in rating of ${rating}, which is invalid. Defaulting to 0.`
);
return 0;
} else if (rating > 1000) {
logger.warn(
`Percent: ${percentDiv100}, Level: ${levelNum} resulted in rating of ${rating}, which is invalid (> 1000). Defaulting to 0.`
);
return 0;
}
// checks for Infinity or NaN. I'm not sure how this would happen, but it's a failsafe.
if (!Number.isFinite(rating)) {
logger.warn(
`Percent: ${percentDiv100}, Level: ${levelNum} resulted in rating of ${rating}, which is invalid. Defaulting to 0.`
);
return 0;
} else if (rating > 1000) {
logger.warn(
`Percent: ${percentDiv100}, Level: ${levelNum} resulted in rating of ${rating}, which is invalid (> 1000). Defaulting to 0.`
);
return 0;
}
return rating;
return rating;
}
function RatingCalcV0Fail(percentDiv100: number, levelNum: number, parameters: RatingParameters) {
// https://www.desmos.com/calculator/ngjie5elto
return (
percentDiv100 ** (parameters.failHarshnessMultiplier * levelNum) *
(levelNum / parameters.pivotPercent ** (parameters.failHarshnessMultiplier * levelNum))
);
// https://www.desmos.com/calculator/ngjie5elto
return (
percentDiv100 ** (parameters.failHarshnessMultiplier * levelNum) *
(levelNum / parameters.pivotPercent ** (parameters.failHarshnessMultiplier * levelNum))
);
}
export async function CalculateKTRating(
dryScore: DryScore,
game: Game,
playtype: Playtypes[Game],
chart: AnyChartDocument,
logger: KtLogger,
defaultTierlistID?: string
dryScore: DryScore,
game: Game,
playtype: Playtypes[Game],
chart: AnyChartDocument,
logger: KtLogger,
defaultTierlistID?: string
) {
// If this game doesn't have a specific rating function declared, fall back to the default "generic" rating function.
// This is just a function that is guaranteed to work for all input - and therefore not result in lower-skilled users
// always seeing 0.
const parameters = ratingParameters[dryScore.game];
// If this game doesn't have a specific rating function declared, fall back to the default "generic" rating function.
// This is just a function that is guaranteed to work for all input - and therefore not result in lower-skilled users
// always seeing 0.
const parameters = ratingParameters[dryScore.game];
let levelNum = chart.levelNum;
let levelNum = chart.levelNum;
if (defaultTierlistID) {
const tierlistData = await GetOneTierlistData(
game,
chart,
"score",
null,
defaultTierlistID
);
if (defaultTierlistID) {
const tierlistData = await GetOneTierlistData(
game,
chart,
"score",
null,
defaultTierlistID
);
if (tierlistData) {
levelNum = tierlistData.data.value;
}
}
if (tierlistData) {
levelNum = tierlistData.data.value;
}
}
return KTRatingCalcV1(dryScore.scoreData.percent, levelNum, parameters, logger);
return KTRatingCalcV1(dryScore.scoreData.percent, levelNum, parameters, logger);
}
export async function CalculateKTLampRating(
dryScore: DryScore,
game: Game,
playtype: Playtypes[Game],
chart: AnyChartDocument,
defaultTierlistID?: string
dryScore: DryScore,
game: Game,
playtype: Playtypes[Game],
chart: AnyChartDocument,
defaultTierlistID?: string
) {
// if no tierlist data
if (!defaultTierlistID) {
return LampRatingNoTierlistInfo(dryScore, game, chart);
}
// if no tierlist data
if (!defaultTierlistID) {
return LampRatingNoTierlistInfo(dryScore, game, chart);
}
const lampTierlistInfo = await GetAllTierlistDataOfType(game, chart, "lamp", defaultTierlistID);
const lampTierlistInfo = await GetAllTierlistDataOfType(game, chart, "lamp", defaultTierlistID);
// if no tierlist info
if (lampTierlistInfo.length === 0) {
return LampRatingNoTierlistInfo(dryScore, game, chart);
}
// if no tierlist info
if (lampTierlistInfo.length === 0) {
return LampRatingNoTierlistInfo(dryScore, game, chart);
}
const userLampIndex = lamps[game].indexOf(dryScore.scoreData.lamp);
const userLampIndex = lamps[game].indexOf(dryScore.scoreData.lamp);
// if this score is a clear, the lowest we should go is the levelNum of the chart.
// Else, the lowest we can go is 0.
let lampRating = userLampIndex >= lamps[game].indexOf(clearLamp[game]) ? chart.levelNum : 0;
// if this score is a clear, the lowest we should go is the levelNum of the chart.
// Else, the lowest we can go is 0.
let lampRating = userLampIndex >= lamps[game].indexOf(clearLamp[game]) ? chart.levelNum : 0;
// why is this like this and not a lookup table?
// Some charts have higher values for *lower* lamps, such as
// one more lovely in IIDX being harder to NC than it is to HC!
// this means we have to iterate over all tierlist data, in general.
for (const tierlistData of lampTierlistInfo) {
if (
// this tierlistData has higher value than the current lampRating
tierlistData.data.value > lampRating &&
// and your clear is better than the lamp its for
lamps[game].indexOf(tierlistData.key!) <= userLampIndex
) {
lampRating = tierlistData.data.value;
}
}
// why is this like this and not a lookup table?
// Some charts have higher values for *lower* lamps, such as
// one more lovely in IIDX being harder to NC than it is to HC!
// this means we have to iterate over all tierlist data, in general.
for (const tierlistData of lampTierlistInfo) {
if (
// this tierlistData has higher value than the current lampRating
tierlistData.data.value > lampRating &&
// and your clear is better than the lamp its for
lamps[game].indexOf(tierlistData.key!) <= userLampIndex
) {
lampRating = tierlistData.data.value;
}
}
return lampRating;
return lampRating;
}
function LampRatingNoTierlistInfo(dryScore: DryScore, game: Game, chart: AnyChartDocument) {
const CLEAR_LAMP_INDEX = lamps[game].indexOf(clearLamp[game]);
const CLEAR_LAMP_INDEX = lamps[game].indexOf(clearLamp[game]);
// if this is a clear
if (lamps[game].indexOf(dryScore.scoreData.lamp) >= CLEAR_LAMP_INDEX) {
// return this chart's numeric level as the lamp rating
return chart.levelNum;
}
// if this is a clear
if (lamps[game].indexOf(dryScore.scoreData.lamp) >= CLEAR_LAMP_INDEX) {
// return this chart's numeric level as the lamp rating
return chart.levelNum;
}
// else, this score is worth 0.
return 0;
// else, this score is worth 0.
return 0;
}
// deprecated calcs
@@ -5,11 +5,11 @@ import { ImportTypes } from "tachi-common";
import { ImportTypeDataMap, ImportTypeContextMap } from "../../import-types/common/types";
export class ConverterFailure {
message: string | null;
message: string | null;
constructor(message: string | null) {
this.message = message;
}
constructor(message: string | null) {
this.message = message;
}
}
/**
@@ -25,22 +25,22 @@ export class SkipScoreFailure extends ConverterFailure {}
* Alongside any other fields used in the query.
*/
export class KTDataNotFoundFailure<T extends ImportTypes> extends ConverterFailure {
data: ImportTypeDataMap[T];
converterContext: ImportTypeContextMap[T];
importType: T;
data: ImportTypeDataMap[T];
converterContext: ImportTypeContextMap[T];
importType: T;
constructor(
message: string | null,
importType: T,
data: ImportTypeDataMap[T],
context: ImportTypeContextMap[T]
) {
super(message);
constructor(
message: string | null,
importType: T,
data: ImportTypeDataMap[T],
context: ImportTypeContextMap[T]
) {
super(message);
this.importType = importType;
this.data = data;
this.converterContext = context;
}
this.importType = importType;
this.data = data;
this.converterContext = context;
}
}
/**
@@ -4,19 +4,19 @@ import { PublicUserDocument } from "tachi-common";
import { CloseAllConnections } from "../../../../test-utils/close-connections";
t.test("#CreateImportLoggerAndID", (t) => {
const { importID, logger } = CreateImportLoggerAndID(
{ username: "foo", id: 1 } as PublicUserDocument,
"file/eamusement-iidx-csv"
);
const { importID, logger } = CreateImportLoggerAndID(
{ username: "foo", id: 1 } as PublicUserDocument,
"file/eamusement-iidx-csv"
);
t.match(importID, /^[a-f0-9]{40}$/u, "Should return a 40 character importID.");
t.match(importID, /^[a-f0-9]{40}$/u, "Should return a 40 character importID.");
// not possible to automate this, probably.
logger.info(
"Namespaced log test - if this does not have 'file/eamusement-iidx-csv | foo (#1)' next to it, it's broke."
);
// not possible to automate this, probably.
logger.info(
"Namespaced log test - if this does not have 'file/eamusement-iidx-csv | foo (#1)' next to it, it's broke."
);
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -4,24 +4,24 @@ import { FormatUserDoc } from "../../../../utils/user";
import { rootLogger, KtLogger } from "../../../logger/logger";
export function CreateImportLoggerAndID(userDoc: PublicUserDocument, importType: ImportTypes) {
const importID = crypto.randomBytes(20).toString("hex");
return { logger: CreateScoreLogger(userDoc, importID, importType), importID };
const importID = crypto.randomBytes(20).toString("hex");
return { logger: CreateScoreLogger(userDoc, importID, importType), importID };
}
export function CreateScoreLogger(
user: PublicUserDocument,
importID: string,
importType: ImportTypes
user: PublicUserDocument,
importID: string,
importType: ImportTypes
): KtLogger {
const meta = {
context: ["Score Import", importType, FormatUserDoc(user)],
importID,
};
const meta = {
context: ["Score Import", importType, FormatUserDoc(user)],
importID,
};
// used so appendLogCtx works
const childLogger = rootLogger.child(meta);
// used so appendLogCtx works
const childLogger = rootLogger.child(meta);
childLogger.defaultMeta = meta;
childLogger.defaultMeta = meta;
return childLogger as KtLogger;
return childLogger as KtLogger;
}
@@ -1,13 +1,13 @@
import {
config,
Game,
Grades,
IDStrings,
AnyChartDocument,
ChartDocument,
GameToIDStrings,
ESDCore,
Playtypes,
config,
Game,
Grades,
IDStrings,
AnyChartDocument,
ChartDocument,
GameToIDStrings,
ESDCore,
Playtypes,
} from "tachi-common";
import { judgementWindows, gamePercentMax } from "tachi-common/js/config";
import CreateLogCtx from "../../../logger/logger";
@@ -19,22 +19,22 @@ const logger = CreateLogCtx(__filename);
* Util for getting a games' grade for a given percent.
*/
export function GetGradeFromPercent<I extends IDStrings = IDStrings>(
game: Game,
percent: number
game: Game,
percent: number
): Grades[I] {
// @todo #102 update config to use game->pt
const boundaries = config.gradeBoundaries[game];
const grades = config.grades[game];
// @todo #102 update config to use game->pt
const boundaries = config.gradeBoundaries[game];
const grades = config.grades[game];
// eslint doesn't like backwards for loops (hey, this for loop is backwards!)
for (let i = boundaries.length; i >= 0; i--) {
if (percent >= boundaries[i]) {
return grades[i] as Grades[I];
}
}
// eslint doesn't like backwards for loops (hey, this for loop is backwards!)
for (let i = boundaries.length; i >= 0; i--) {
if (percent >= boundaries[i]) {
return grades[i] as Grades[I];
}
}
logger.error(`Could not resolve grade for percent ${percent} on game ${game}`);
throw new InternalFailure(`Could not resolve grade for percent ${percent} on game ${game}`);
logger.error(`Could not resolve grade for percent ${percent} on game ${game}`);
throw new InternalFailure(`Could not resolve grade for percent ${percent} on game ${game}`);
}
/**
@@ -42,44 +42,44 @@ export function GetGradeFromPercent<I extends IDStrings = IDStrings>(
* a given game.
*/
export function GenericCalculatePercent(
game: Game,
score: number,
chart?: AnyChartDocument
game: Game,
score: number,
chart?: AnyChartDocument
): number {
switch (game) {
case "ddr":
case "museca":
case "jubeat":
case "chunithm":
return (score / 1_000_000) * 100;
case "sdvx":
case "usc":
return (score / 10_000_000) * 100;
case "popn":
return (score / 100_000) * 100;
case "gitadora":
case "maimai":
return score;
case "bms":
case "iidx":
if (!chart) {
logger.severe("No Chart passed to GenericCalcPercent but game was iidx/bms.");
throw new InternalFailure(
"No Chart passed to GenericCalcPercent but game was iidx/bms."
);
}
switch (game) {
case "ddr":
case "museca":
case "jubeat":
case "chunithm":
return (score / 1_000_000) * 100;
case "sdvx":
case "usc":
return (score / 10_000_000) * 100;
case "popn":
return (score / 100_000) * 100;
case "gitadora":
case "maimai":
return score;
case "bms":
case "iidx":
if (!chart) {
logger.severe("No Chart passed to GenericCalcPercent but game was iidx/bms.");
throw new InternalFailure(
"No Chart passed to GenericCalcPercent but game was iidx/bms."
);
}
// Yeah, we declare it like this so the below return is actually clear.
// eslint-disable-next-line no-case-declarations
const MAX =
(chart as ChartDocument<"iidx:SP" | "bms:7K" | "bms:14K" | "iidx:DP">).data
.notecount * 2;
// Yeah, we declare it like this so the below return is actually clear.
// eslint-disable-next-line no-case-declarations
const MAX =
(chart as ChartDocument<"iidx:SP" | "bms:7K" | "bms:14K" | "iidx:DP">).data
.notecount * 2;
return (100 * score) / MAX;
default:
logger.severe(`Invalid game passed of ${game} to GenericCalcPercent.`);
throw new InternalFailure(`Invalid game passed of ${game} to GenericCalcPercent.`);
}
return (100 * score) / MAX;
default:
logger.severe(`Invalid game passed of ${game} to GenericCalcPercent.`);
throw new InternalFailure(`Invalid game passed of ${game} to GenericCalcPercent.`);
}
}
/**
@@ -89,21 +89,21 @@ export function GenericCalculatePercent(
* This exists to support maimai, as it has a dynamic "max percent".
*/
export function ValidatePercent(game: Game, percent: number, chart: AnyChartDocument) {
// i love needing a helper function for *ONE* game.
if (game === "maimai") {
const mmChart = chart as ChartDocument<"maimai:Single">;
if (percent > mmChart.data.maxPercent) {
throw new InvalidScoreFailure(
`Invalid percent - expected less than ${mmChart.data.maxPercent}.`
);
}
}
// i love needing a helper function for *ONE* game.
if (game === "maimai") {
const mmChart = chart as ChartDocument<"maimai:Single">;
if (percent > mmChart.data.maxPercent) {
throw new InvalidScoreFailure(
`Invalid percent - expected less than ${mmChart.data.maxPercent}.`
);
}
}
if (percent > gamePercentMax[game]) {
throw new InvalidScoreFailure(
`Invalid percent of ${percent} - expected a value less than ${gamePercentMax[game]}% (${chart.songID} ${chart.playtype} ${chart.difficulty}).`
);
}
if (percent > gamePercentMax[game]) {
throw new InvalidScoreFailure(
`Invalid percent of ${percent} - expected a value less than ${gamePercentMax[game]}% (${chart.songID} ${chart.playtype} ${chart.difficulty}).`
);
}
}
/**
@@ -112,17 +112,17 @@ export function ValidatePercent(game: Game, percent: number, chart: AnyChartDocu
* or if the grade is invalid.
*/
export function GenericGetGradeAndPercent<G extends Game>(
game: G,
score: number,
chart: AnyChartDocument
game: G,
score: number,
chart: AnyChartDocument
) {
const percent = GenericCalculatePercent(game, score, chart);
const percent = GenericCalculatePercent(game, score, chart);
ValidatePercent(game, percent, chart);
ValidatePercent(game, percent, chart);
const grade = GetGradeFromPercent(game, percent) as Grades[GameToIDStrings[G]];
const grade = GetGradeFromPercent(game, percent) as Grades[GameToIDStrings[G]];
return { percent, grade };
return { percent, grade };
}
/**
@@ -130,24 +130,24 @@ export function GenericGetGradeAndPercent<G extends Game>(
* null if the game does not support support ESD.
*/
export function CalculateESDForGame(
game: Game,
playtype: Playtypes[Game],
percent: number
game: Game,
playtype: Playtypes[Game],
percent: number
): number | null {
if (
Object.prototype.hasOwnProperty.call(judgementWindows, game) &&
// @ts-expect-error i know better
Object.prototype.hasOwnProperty.call(judgementWindows[game], playtype)
) {
if (percent > 0.1) {
// @ts-expect-error i know better
return ESDCore.CalculateESD(judgementWindows[game][playtype], percent);
} else {
return 200;
}
}
if (
Object.prototype.hasOwnProperty.call(judgementWindows, game) &&
// @ts-expect-error i know better
Object.prototype.hasOwnProperty.call(judgementWindows[game], playtype)
) {
if (percent > 0.1) {
// @ts-expect-error i know better
return ESDCore.CalculateESD(judgementWindows[game][playtype], percent);
} else {
return 200;
}
}
return null;
return null;
}
/**
@@ -155,15 +155,15 @@ export function CalculateESDForGame(
* @returns Miliseconds from the unix epoch, or null if the initial argument was null or undefined.
*/
export function ParseDateFromString(str: string | undefined | null): number | null {
if (!str) {
return null;
}
if (!str) {
return null;
}
const date = Date.parse(str);
const date = Date.parse(str);
if (Number.isNaN(date)) {
throw new InvalidScoreFailure(`Invalid/Unparsable score timestamp of ${str}.`);
}
if (Number.isNaN(date)) {
throw new InvalidScoreFailure(`Invalid/Unparsable score timestamp of ${str}.`);
}
return date;
return date;
}
@@ -3,177 +3,177 @@ import { InvalidScoreFailure } from "./converter-failures";
import { AssertStrAsPositiveInt, AssertStrAsPositiveNonZeroInt } from "./string-asserts";
function astr(v: string) {
try {
const r = AssertStrAsPositiveInt(v, "err");
try {
const r = AssertStrAsPositiveInt(v, "err");
return r;
} catch (e) {
return e;
}
return r;
} catch (e) {
return e;
}
}
function astrp(v: string) {
try {
const r = AssertStrAsPositiveNonZeroInt(v, "err");
try {
const r = AssertStrAsPositiveNonZeroInt(v, "err");
return r;
} catch (e) {
return e;
}
return r;
} catch (e) {
return e;
}
}
t.test("#AssertStrAsPositiveInt", (t) => {
t.strictSame(
astr("---"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject non-int input"
);
t.strictSame(
astr("---"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject non-int input"
);
t.strictSame(
astr("1.4"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject float input"
);
t.strictSame(
astr("1.4"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject float input"
);
t.strictSame(
astr("NaN"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject NaN"
);
t.strictSame(
astr("-0.3"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject negative float input"
);
t.strictSame(
astr("NaN"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject NaN"
);
t.strictSame(
astr("-0.3"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject negative float input"
);
t.strictSame(
astr("0xFF"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject hex input"
);
t.strictSame(
astr("0xFF"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject hex input"
);
t.strictSame(
astr("0b11"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject binary input"
);
t.strictSame(
astr("0b11"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject binary input"
);
t.strictSame(
astr("0o77"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject oct input"
);
t.strictSame(
astr("0o77"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject oct input"
);
t.strictSame(
astr("--1"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject double neg input"
);
t.strictSame(
astr("--1"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject double neg input"
);
t.strictSame(
astr("12f"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject valid parseInt but invalid Number() input"
);
t.strictSame(
astr("12f"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject valid parseInt but invalid Number() input"
);
t.strictSame(
astr(`${Number.MAX_SAFE_INTEGER.toString()}000`),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject numbers > max_safe_integer."
);
t.strictSame(
astr(`${Number.MAX_SAFE_INTEGER.toString()}000`),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject numbers > max_safe_integer."
);
t.strictSame(
astr("-1"),
new InvalidScoreFailure(`err (Was negative.)`),
"Should reject negative integer input"
);
t.strictSame(
astr("-1"),
new InvalidScoreFailure(`err (Was negative.)`),
"Should reject negative integer input"
);
t.strictSame(astr("-0"), 0, "Should allow negative 0 input");
t.strictSame(astr("0"), 0, "Should allow 0 input");
t.strictSame(astr("13"), 13, "Should allow valid int input");
t.strictSame(astr("-0"), 0, "Should allow negative 0 input");
t.strictSame(astr("0"), 0, "Should allow 0 input");
t.strictSame(astr("13"), 13, "Should allow valid int input");
t.end();
t.end();
});
t.test("#AssertStrAsPositiveNonZeroInt", (t) => {
t.strictSame(
astrp("---"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject non-int input"
);
t.strictSame(
astrp("---"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject non-int input"
);
t.strictSame(
astrp("1.4"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject float input"
);
t.strictSame(
astrp("1.4"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject float input"
);
t.strictSame(
astrp("NaN"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject NaN"
);
t.strictSame(
astrp("-0.3"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject negative float input"
);
t.strictSame(
astrp("NaN"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject NaN"
);
t.strictSame(
astrp("-0.3"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject negative float input"
);
t.strictSame(
astrp("0xFF"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject hex input"
);
t.strictSame(
astrp("0xFF"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject hex input"
);
t.strictSame(
astrp("0b11"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject binary input"
);
t.strictSame(
astrp("0b11"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject binary input"
);
t.strictSame(
astrp("0o77"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject oct input"
);
t.strictSame(
astrp("0o77"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject oct input"
);
t.strictSame(
astrp("--1"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject double neg input"
);
t.strictSame(
astrp("--1"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject double neg input"
);
t.strictSame(
astrp("12f"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject valid parseInt but invalid Number() input"
);
t.strictSame(
astrp("12f"),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject valid parseInt but invalid Number() input"
);
t.strictSame(
astrp(`${Number.MAX_SAFE_INTEGER.toString()}000`),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject numbers > max_safe_integer."
);
t.strictSame(
astrp(`${Number.MAX_SAFE_INTEGER.toString()}000`),
new InvalidScoreFailure(`err (Not an integer.)`),
"Should reject numbers > max_safe_integer."
);
t.strictSame(
astrp("-1"),
new InvalidScoreFailure(`err (Was negative or zero.)`),
"Should reject negative integer input"
);
t.strictSame(
astrp("-1"),
new InvalidScoreFailure(`err (Was negative or zero.)`),
"Should reject negative integer input"
);
t.strictSame(
astrp("-0"),
new InvalidScoreFailure(`err (Was negative or zero.)`),
"Should reject negative 0 input"
);
t.strictSame(
astrp("-0"),
new InvalidScoreFailure(`err (Was negative or zero.)`),
"Should reject negative 0 input"
);
t.strictSame(
astrp("0"),
new InvalidScoreFailure(`err (Was negative or zero.)`),
"Should reject 0 input"
);
t.strictSame(
astrp("0"),
new InvalidScoreFailure(`err (Was negative or zero.)`),
"Should reject 0 input"
);
t.strictSame(astrp("13"), 13, "Should allow valid int input");
t.strictSame(astrp("13"), 13, "Should allow valid int input");
t.end();
t.end();
});
@@ -3,55 +3,55 @@ import { Difficulties, Game, IDStrings, Playtypes } from "tachi-common";
import { validDifficulties } from "tachi-common/js/config";
export function AssertStrAsDifficulty(
strVal: string,
game: Game,
playtype: Playtypes[Game]
strVal: string,
game: Game,
playtype: Playtypes[Game]
): Difficulties[IDStrings] {
if (!validDifficulties[game].includes(strVal)) {
throw new InvalidScoreFailure(
`Invalid Difficulty for ${game} ${playtype} - Expected any of ${validDifficulties[
game
].join(", ")}`
);
}
if (!validDifficulties[game].includes(strVal)) {
throw new InvalidScoreFailure(
`Invalid Difficulty for ${game} ${playtype} - Expected any of ${validDifficulties[
game
].join(", ")}`
);
}
return strVal as Difficulties[IDStrings];
return strVal as Difficulties[IDStrings];
}
const isIntegerRegex = /^-?\d+$/u;
export function AssertStrAsPositiveInt(strVal: string, errorMessage: string) {
const isInt = isIntegerRegex.test(strVal);
const isInt = isIntegerRegex.test(strVal);
if (!isInt) {
throw new InvalidScoreFailure(`${errorMessage} (Not an integer.)`);
}
if (!isInt) {
throw new InvalidScoreFailure(`${errorMessage} (Not an integer.)`);
}
const val = Number(strVal);
const val = Number(strVal);
if (!Number.isSafeInteger(val)) {
throw new InvalidScoreFailure(`${errorMessage} (Not an integer.)`);
} else if (val < 0) {
throw new InvalidScoreFailure(`${errorMessage} (Was negative.)`);
}
if (!Number.isSafeInteger(val)) {
throw new InvalidScoreFailure(`${errorMessage} (Not an integer.)`);
} else if (val < 0) {
throw new InvalidScoreFailure(`${errorMessage} (Was negative.)`);
}
return val;
return val;
}
export function AssertStrAsPositiveNonZeroInt(strVal: string, errorMessage: string) {
const isInt = isIntegerRegex.test(strVal);
const isInt = isIntegerRegex.test(strVal);
if (!isInt) {
throw new InvalidScoreFailure(`${errorMessage} (Not an integer.)`);
}
if (!isInt) {
throw new InvalidScoreFailure(`${errorMessage} (Not an integer.)`);
}
const val = Number(strVal);
const val = Number(strVal);
if (!Number.isSafeInteger(val)) {
throw new InvalidScoreFailure(`${errorMessage} (Not an integer.)`);
} else if (val <= 0) {
throw new InvalidScoreFailure(`${errorMessage} (Was negative or zero.)`);
}
if (!Number.isSafeInteger(val)) {
throw new InvalidScoreFailure(`${errorMessage} (Not an integer.)`);
} else if (val <= 0) {
throw new InvalidScoreFailure(`${errorMessage} (Was negative or zero.)`);
}
return val;
return val;
}
@@ -5,10 +5,10 @@ import { IDStrings, ScoreDocument, Playtypes, Game } from "tachi-common";
* HydrateScore.
*/
export type DryScore<I extends IDStrings = IDStrings> = Pick<
ScoreDocument<I>,
"service" | "game" | "scoreMeta" | "timeAchieved" | "comment" | "importType"
ScoreDocument<I>,
"service" | "game" | "scoreMeta" | "timeAchieved" | "comment" | "importType"
> & {
scoreData: Omit<ScoreDocument<I>["scoreData"], "gradeIndex" | "lampIndex" | "esd">;
scoreData: Omit<ScoreDocument<I>["scoreData"], "gradeIndex" | "lampIndex" | "esd">;
};
export type ScorePlaytypeMap = Partial<Record<Playtypes[Game], ScoreDocument[]>>;
@@ -1,9 +1,9 @@
import {
ImportTypes,
PublicUserDocument,
SuccessfulAPIResponse,
UnsuccessfulAPIResponse,
ImportDocument,
ImportTypes,
PublicUserDocument,
SuccessfulAPIResponse,
UnsuccessfulAPIResponse,
ImportDocument,
} from "tachi-common";
import { ImportInputParser } from "../import-types/common/types";
import { CreateImportLoggerAndID } from "./common/import-logger";
@@ -11,8 +11,8 @@ import ScoreImportMain from "./score-import-main";
import ScoreImportFatalError from "./score-importing/score-import-error";
export interface WrappedAPIResponse {
statusCode: number;
body: SuccessfulAPIResponse<ImportDocument> | UnsuccessfulAPIResponse;
statusCode: number;
body: SuccessfulAPIResponse<ImportDocument> | UnsuccessfulAPIResponse;
}
/**
@@ -21,48 +21,48 @@ export interface WrappedAPIResponse {
* be immediately sent with res.json().
*/
export async function ExpressWrappedScoreImportMain<D, C>(
user: PublicUserDocument,
userIntent: boolean,
importType: ImportTypes,
InputParser: ImportInputParser<D, C>
user: PublicUserDocument,
userIntent: boolean,
importType: ImportTypes,
InputParser: ImportInputParser<D, C>
): Promise<WrappedAPIResponse> {
const { importID, logger } = CreateImportLoggerAndID(user, importType);
logger.debug("Received import request.");
const { importID, logger } = CreateImportLoggerAndID(user, importType);
logger.debug("Received import request.");
try {
const res = await ScoreImportMain(user, userIntent, importType, InputParser, {
importID,
logger,
});
try {
const res = await ScoreImportMain(user, userIntent, importType, InputParser, {
importID,
logger,
});
return {
statusCode: 200,
body: {
success: true,
description: "Import successful.",
body: res,
},
};
} catch (err) {
if (err instanceof ScoreImportFatalError) {
logger.info(err.message);
return {
statusCode: 400,
body: {
success: false,
description: err.message,
},
};
}
return {
statusCode: 200,
body: {
success: true,
description: "Import successful.",
body: res,
},
};
} catch (err) {
if (err instanceof ScoreImportFatalError) {
logger.info(err.message);
return {
statusCode: 400,
body: {
success: false,
description: err.message,
},
};
}
logger.error(err);
return {
statusCode: 500,
body: {
success: false,
description:
"An internal service error has occured. Please do not repeat this request.",
},
};
}
logger.error(err);
return {
statusCode: 500,
body: {
success: false,
description:
"An internal service error has occured. Please do not repeat this request.",
},
};
}
}
@@ -5,12 +5,12 @@ import { GetRelevantFolderGoals, GetRelevantGoals, UpdateGoalsForUser, ProcessGo
import { GoalDocument, UserGoalDocument } from "tachi-common";
import { CreateFolderChartLookup } from "../../../../utils/folder";
import {
GetKTDataJSON,
Testing511SPA,
TestingIIDXSPScorePB,
HC511Goal,
HC511UserGoal,
TestingIIDXFolderSP10,
GetKTDataJSON,
Testing511SPA,
TestingIIDXSPScorePB,
HC511Goal,
HC511UserGoal,
TestingIIDXFolderSP10,
} from "../../../../test-utils/test-data";
import deepmerge from "deepmerge";
import CreateLogCtx from "../../../logger/logger";
@@ -20,403 +20,403 @@ import { CloseAllConnections } from "../../../../test-utils/close-connections";
const logger = CreateLogCtx(__filename);
t.test("#GetRelevantFolderGoals", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
const fakeFolderGoalDocument: GoalDocument = {
charts: {
type: "folder",
data: "ed9d8c734447ce67d7135c0067441a98cc81aeaf",
},
createdBy: 1,
game: "iidx",
goalID: "fake_goal_id",
playtype: "SP",
timeAdded: 0,
title: "get > 1 ex score on any level 10.",
criteria: {
mode: "single",
value: 1,
key: "scoreData.score",
},
};
const fakeFolderGoalDocument: GoalDocument = {
charts: {
type: "folder",
data: "ed9d8c734447ce67d7135c0067441a98cc81aeaf",
},
createdBy: 1,
game: "iidx",
goalID: "fake_goal_id",
playtype: "SP",
timeAdded: 0,
title: "get > 1 ex score on any level 10.",
criteria: {
mode: "single",
value: 1,
key: "scoreData.score",
},
};
const notFolderGoalDocument: GoalDocument = {
charts: {
type: "folder",
data: "some_fake_folder_id",
},
createdBy: 1,
game: "iidx",
goalID: "fake_bad_goal_id",
playtype: "SP",
timeAdded: 0,
title: "get > 1 ex score on some other folder.",
criteria: {
mode: "single",
value: 1,
key: "scoreData.score",
},
};
const notFolderGoalDocument: GoalDocument = {
charts: {
type: "folder",
data: "some_fake_folder_id",
},
createdBy: 1,
game: "iidx",
goalID: "fake_bad_goal_id",
playtype: "SP",
timeAdded: 0,
title: "get > 1 ex score on some other folder.",
criteria: {
mode: "single",
value: 1,
key: "scoreData.score",
},
};
t.beforeEach(async () => {
// @ts-expect-error garbage types
const sp11folder = deepmerge(TestingIIDXFolderSP10, { data: { level: "11" } });
await db.folders.insert(TestingIIDXFolderSP10);
await db.folders.insert(sp11folder);
await db.goals.insert(fakeFolderGoalDocument);
await db.goals.insert(notFolderGoalDocument);
await CreateFolderChartLookup(TestingIIDXFolderSP10);
await CreateFolderChartLookup(sp11folder);
});
t.beforeEach(async () => {
// @ts-expect-error garbage types
const sp11folder = deepmerge(TestingIIDXFolderSP10, { data: { level: "11" } });
await db.folders.insert(TestingIIDXFolderSP10);
await db.folders.insert(sp11folder);
await db.goals.insert(fakeFolderGoalDocument);
await db.goals.insert(notFolderGoalDocument);
await CreateFolderChartLookup(TestingIIDXFolderSP10);
await CreateFolderChartLookup(sp11folder);
});
t.test("Should correctly find the goals on this folder.", async (t) => {
const res = await GetRelevantFolderGoals(
["fake_goal_id", "fake_bad_goal_id"],
[Testing511SPA.chartID]
);
t.test("Should correctly find the goals on this folder.", async (t) => {
const res = await GetRelevantFolderGoals(
["fake_goal_id", "fake_bad_goal_id"],
[Testing511SPA.chartID]
);
t.strictSame(
res,
[fakeFolderGoalDocument],
"Should correctly return only the 511 goal document."
);
t.strictSame(
res,
[fakeFolderGoalDocument],
"Should correctly return only the 511 goal document."
);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.test("#GetRelevantGoals", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(async () => {
// use the real data so we have enough charts loaded for this to test properly.
await db.songs.iidx.remove({});
await db.charts.iidx.remove({});
await db.charts.iidx.insert(GetKTDataJSON("./tachi/ktblack-charts-iidx.json"));
await db.songs.iidx.insert(GetKTDataJSON("./tachi/ktblack-songs-iidx.json"));
t.beforeEach(ResetDBState);
t.beforeEach(async () => {
// use the real data so we have enough charts loaded for this to test properly.
await db.songs.iidx.remove({});
await db.charts.iidx.remove({});
await db.charts.iidx.insert(GetKTDataJSON("./tachi/ktblack-charts-iidx.json"));
await db.songs.iidx.insert(GetKTDataJSON("./tachi/ktblack-songs-iidx.json"));
const lotsOfCharts = await db.charts.iidx.find({}, { limit: 20 });
const goals: GoalDocument[] = lotsOfCharts.map((e) => ({
charts: {
type: "single",
data: e.chartID,
},
createdBy: 1,
game: "iidx",
goalID: crypto.randomBytes(20).toString("hex"),
playtype: "SP",
timeAdded: 0,
title: "get > 1 ex score on some other folder.",
criteria: {
mode: "single",
value: 1,
key: "scoreData.score",
},
}));
const lotsOfCharts = await db.charts.iidx.find({}, { limit: 20 });
const goals: GoalDocument[] = lotsOfCharts.map((e) => ({
charts: {
type: "single",
data: e.chartID,
},
createdBy: 1,
game: "iidx",
goalID: crypto.randomBytes(20).toString("hex"),
playtype: "SP",
timeAdded: 0,
title: "get > 1 ex score on some other folder.",
criteria: {
mode: "single",
value: 1,
key: "scoreData.score",
},
}));
await db.goals.insert(goals);
await db.goals.insert(goals);
await db["user-goals"].insert(
goals.map((e) => ({
achieved: false,
timeAchieved: null,
game: "iidx",
playtype: "SP",
goalID: e.goalID,
lastInteraction: null,
outOf: 5,
outOfHuman: "HARD CLEAR",
progress: null,
progressHuman: "NO DATA",
timeSet: Date.now(),
userID: 1,
}))
);
});
await db["user-goals"].insert(
goals.map((e) => ({
achieved: false,
timeAchieved: null,
game: "iidx",
playtype: "SP",
goalID: e.goalID,
lastInteraction: null,
outOf: 5,
outOfHuman: "HARD CLEAR",
progress: null,
progressHuman: "NO DATA",
timeSet: Date.now(),
userID: 1,
}))
);
});
t.test(
"Should successfully filter goals to only those that are relevant to the session.",
async (t) => {
// lets pretend our session had scores on charts 0->4 and 20->25. We also only have goals on
// charts 1->20, so only 5 of these should resolve.
const ourCharts = [
...(await db.charts.iidx.find({}, { limit: 5 })),
...(await db.charts.iidx.find({}, { skip: 20, limit: 5 })),
];
t.test(
"Should successfully filter goals to only those that are relevant to the session.",
async (t) => {
// lets pretend our session had scores on charts 0->4 and 20->25. We also only have goals on
// charts 1->20, so only 5 of these should resolve.
const ourCharts = [
...(await db.charts.iidx.find({}, { limit: 5 })),
...(await db.charts.iidx.find({}, { skip: 20, limit: 5 })),
];
const chartIDs = new Set(ourCharts.map((e) => e.chartID));
const chartIDs = new Set(ourCharts.map((e) => e.chartID));
const res = await GetRelevantGoals("iidx", 1, chartIDs, logger);
const res = await GetRelevantGoals("iidx", 1, chartIDs, logger);
t.equal(res.goals.length, 5, "Should correctly resolve to 5 goals.");
t.equal(res.userGoalsMap.size, 5, "Should also return 5 userGoals.");
t.equal(res.goals.length, 5, "Should correctly resolve to 5 goals.");
t.equal(res.userGoalsMap.size, 5, "Should also return 5 userGoals.");
t.end();
}
);
t.end();
}
);
t.end();
t.end();
});
t.test("#UpdateGoalsForUser", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
const baseGoalDocument: GoalDocument = {
charts: {
type: "single" as const,
data: Testing511SPA.chartID,
},
createdBy: 1,
game: "iidx",
goalID: "FAKE_GOAL_ID",
playtype: "SP",
timeAdded: 0,
title: "get > 1 ex score on some other folder.",
criteria: {
mode: "single",
value: 1,
key: "scoreData.score",
},
};
const baseGoalDocument: GoalDocument = {
charts: {
type: "single" as const,
data: Testing511SPA.chartID,
},
createdBy: 1,
game: "iidx",
goalID: "FAKE_GOAL_ID",
playtype: "SP",
timeAdded: 0,
title: "get > 1 ex score on some other folder.",
criteria: {
mode: "single",
value: 1,
key: "scoreData.score",
},
};
const baseUserGoalDocument: UserGoalDocument = {
achieved: false,
game: "iidx",
playtype: "SP",
goalID: "FAKE_GOAL_ID",
lastInteraction: null,
outOf: 1,
outOfHuman: "1",
progress: 0,
progressHuman: "0",
timeAchieved: null,
timeSet: 0,
userID: 1,
};
const baseUserGoalDocument: UserGoalDocument = {
achieved: false,
game: "iidx",
playtype: "SP",
goalID: "FAKE_GOAL_ID",
lastInteraction: null,
outOf: 1,
outOfHuman: "1",
progress: 0,
progressHuman: "0",
timeAchieved: null,
timeSet: 0,
userID: 1,
};
t.test("Should correctly update goals when user achieves goal.", async (t) => {
await db.goals.insert(baseGoalDocument);
delete baseGoalDocument._id;
t.test("Should correctly update goals when user achieves goal.", async (t) => {
await db.goals.insert(baseGoalDocument);
delete baseGoalDocument._id;
await db["user-goals"].insert(baseUserGoalDocument);
// we dont delete _id here because updategoalsforuser
// depends on usergoal _id
await db["user-goals"].insert(baseUserGoalDocument);
// we dont delete _id here because updategoalsforuser
// depends on usergoal _id
await db["personal-bests"].insert(TestingIIDXSPScorePB);
delete TestingIIDXSPScorePB._id;
await db["personal-bests"].insert(TestingIIDXSPScorePB);
delete TestingIIDXSPScorePB._id;
const ugMap = new Map([["FAKE_GOAL_ID", baseUserGoalDocument]]);
const ugMap = new Map([["FAKE_GOAL_ID", baseUserGoalDocument]]);
const res = await UpdateGoalsForUser([baseGoalDocument], ugMap, 1, logger);
const res = await UpdateGoalsForUser([baseGoalDocument], ugMap, 1, logger);
t.strictSame(res, [
{
goalID: "FAKE_GOAL_ID",
old: {
progress: 0,
progressHuman: "0",
outOf: 1,
outOfHuman: "1",
achieved: false,
},
new: {
progress: 1479,
progressHuman: "1479",
outOf: 1,
outOfHuman: "1",
achieved: true,
},
},
]);
t.strictSame(res, [
{
goalID: "FAKE_GOAL_ID",
old: {
progress: 0,
progressHuman: "0",
outOf: 1,
outOfHuman: "1",
achieved: false,
},
new: {
progress: 1479,
progressHuman: "1479",
outOf: 1,
outOfHuman: "1",
achieved: true,
},
},
]);
const r = await db["user-goals"].findOne({ goalID: "FAKE_GOAL_ID", userID: 1 });
const r = await db["user-goals"].findOne({ goalID: "FAKE_GOAL_ID", userID: 1 });
t.hasStrict(
r,
{
progress: 1479,
progressHuman: "1479",
outOf: 1,
outOfHuman: "1",
achieved: true,
},
"Should update goals in the database."
);
t.hasStrict(
r,
{
progress: 1479,
progressHuman: "1479",
outOf: 1,
outOfHuman: "1",
achieved: true,
},
"Should update goals in the database."
);
delete baseUserGoalDocument._id;
delete baseUserGoalDocument._id;
t.end();
});
t.end();
});
t.test("Should correctly update goals when user does not achieve goal.", async (t) => {
const goal = deepmerge(baseGoalDocument, { criteria: { value: 2 } });
await db.goals.insert(goal);
t.test("Should correctly update goals when user does not achieve goal.", async (t) => {
const goal = deepmerge(baseGoalDocument, { criteria: { value: 2 } });
await db.goals.insert(goal);
const userGoal = deepmerge(baseUserGoalDocument, {
outOf: 2,
outOfHuman: "2",
}) as unknown as UserGoalDocument;
const userGoal = deepmerge(baseUserGoalDocument, {
outOf: 2,
outOfHuman: "2",
}) as unknown as UserGoalDocument;
await db["user-goals"].insert(userGoal);
// we dont delete _id here because updategoalsforuser
// depends on usergoal _id
await db["user-goals"].insert(userGoal);
// we dont delete _id here because updategoalsforuser
// depends on usergoal _id
await db["personal-bests"].insert(
deepmerge(TestingIIDXSPScorePB, { scoreData: { score: 1 } })
);
await db["personal-bests"].insert(
deepmerge(TestingIIDXSPScorePB, { scoreData: { score: 1 } })
);
const ugMap = new Map([["FAKE_GOAL_ID", userGoal]]);
const ugMap = new Map([["FAKE_GOAL_ID", userGoal]]);
const res = await UpdateGoalsForUser([goal], ugMap, 1, logger);
const res = await UpdateGoalsForUser([goal], ugMap, 1, logger);
t.strictSame(res, [
{
goalID: "FAKE_GOAL_ID",
old: {
progress: 0,
progressHuman: "0",
outOf: 2,
outOfHuman: "2",
achieved: false,
},
new: {
progress: 1,
progressHuman: "1",
outOf: 2,
outOfHuman: "2",
achieved: false,
},
},
]);
t.strictSame(res, [
{
goalID: "FAKE_GOAL_ID",
old: {
progress: 0,
progressHuman: "0",
outOf: 2,
outOfHuman: "2",
achieved: false,
},
new: {
progress: 1,
progressHuman: "1",
outOf: 2,
outOfHuman: "2",
achieved: false,
},
},
]);
const r = await db["user-goals"].findOne({ goalID: "FAKE_GOAL_ID", userID: 1 });
const r = await db["user-goals"].findOne({ goalID: "FAKE_GOAL_ID", userID: 1 });
t.hasStrict(
r,
{
progress: 1,
progressHuman: "1",
outOf: 2,
outOfHuman: "2",
achieved: false,
},
"Should update goals in the database."
);
t.hasStrict(
r,
{
progress: 1,
progressHuman: "1",
outOf: 2,
outOfHuman: "2",
achieved: false,
},
"Should update goals in the database."
);
delete baseUserGoalDocument._id;
delete baseUserGoalDocument._id;
t.end();
});
t.end();
});
t.test("Should return [] if no data is to be changed.", async (t) => {
const res = await UpdateGoalsForUser([], new Map(), 1, logger);
t.test("Should return [] if no data is to be changed.", async (t) => {
const res = await UpdateGoalsForUser([], new Map(), 1, logger);
t.strictSame(res, []);
t.strictSame(res, []);
t.end();
});
t.end();
});
t.test("Should handle (skip) goals if no usergoal is set.", async (t) => {
const res = await UpdateGoalsForUser([baseGoalDocument], new Map(), 1, logger);
t.test("Should handle (skip) goals if no usergoal is set.", async (t) => {
const res = await UpdateGoalsForUser([baseGoalDocument], new Map(), 1, logger);
t.strictSame(res, []);
t.strictSame(res, []);
t.end();
});
t.end();
});
t.test("Should handle (skip) invalid goals.", async (t) => {
const res = await UpdateGoalsForUser(
[deepmerge(baseGoalDocument, { charts: { type: "INVALID" } })],
new Map([["FAKE_GOAL_ID", baseUserGoalDocument]]),
1,
logger
);
t.test("Should handle (skip) invalid goals.", async (t) => {
const res = await UpdateGoalsForUser(
[deepmerge(baseGoalDocument, { charts: { type: "INVALID" } })],
new Map([["FAKE_GOAL_ID", baseUserGoalDocument]]),
1,
logger
);
t.strictSame(res, []);
t.strictSame(res, []);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.todo("#GetAndUpdateUsersGoals", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.end();
t.end();
});
t.test("#ProcessGoal", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(async () => {
await db.goals.insert(HC511Goal);
});
t.beforeEach(ResetDBState);
t.beforeEach(async () => {
await db.goals.insert(HC511Goal);
});
t.test("Should process the users goal if a score has changed.", async (t) => {
await db["user-goals"].insert(HC511UserGoal);
await db["personal-bests"].insert(TestingIIDXSPScorePB); // score is EX HARD CLEAR by default.
t.test("Should process the users goal if a score has changed.", async (t) => {
await db["user-goals"].insert(HC511UserGoal);
await db["personal-bests"].insert(TestingIIDXSPScorePB); // score is EX HARD CLEAR by default.
const res = await ProcessGoal(HC511Goal, HC511UserGoal, 1, logger);
const res = await ProcessGoal(HC511Goal, HC511UserGoal, 1, logger);
t.not(res, undefined, "Should NOT return undefined.");
t.not(res, undefined, "Should NOT return undefined.");
t.strictSame(
res!.import,
{
goalID: "mock_goalID",
old: {
progress: null,
progressHuman: "NO DATA",
outOf: 5,
outOfHuman: "HARD CLEAR",
achieved: false,
},
new: {
progress: 6,
progressHuman: "EX HARD CLEAR (BP: 2)",
outOf: 5,
outOfHuman: "HARD CLEAR",
achieved: true,
},
},
"Should return a valid import goal fragment."
);
t.strictSame(
res!.import,
{
goalID: "mock_goalID",
old: {
progress: null,
progressHuman: "NO DATA",
outOf: 5,
outOfHuman: "HARD CLEAR",
achieved: false,
},
new: {
progress: 6,
progressHuman: "EX HARD CLEAR (BP: 2)",
outOf: 5,
outOfHuman: "HARD CLEAR",
achieved: true,
},
},
"Should return a valid import goal fragment."
);
t.end();
});
t.end();
});
t.test("Should return undefined if there's no score.", async (t) => {
await db["user-goals"].insert(HC511UserGoal);
t.test("Should return undefined if there's no score.", async (t) => {
await db["user-goals"].insert(HC511UserGoal);
const res = await ProcessGoal(HC511Goal, HC511UserGoal, 1, logger);
const res = await ProcessGoal(HC511Goal, HC511UserGoal, 1, logger);
t.equal(res, undefined, "Should return undefined.");
t.equal(res, undefined, "Should return undefined.");
t.end();
});
t.end();
});
t.test("Should return undefined if the progress has not changed.", async (t) => {
await db["user-goals"].insert(HC511UserGoal);
await db["personal-bests"].insert(TestingIIDXSPScorePB);
t.test("Should return undefined if the progress has not changed.", async (t) => {
await db["user-goals"].insert(HC511UserGoal);
await db["personal-bests"].insert(TestingIIDXSPScorePB);
const firstUpdate = await ProcessGoal(HC511Goal, HC511UserGoal, 1, logger);
const firstUpdate = await ProcessGoal(HC511Goal, HC511UserGoal, 1, logger);
// ignore this one
t.not(firstUpdate, undefined, "Should NOT return undefined.");
// ignore this one
t.not(firstUpdate, undefined, "Should NOT return undefined.");
await db["user-goals"].bulkWrite([firstUpdate!.bwrite]);
await db["user-goals"].bulkWrite([firstUpdate!.bwrite]);
const userGoal = await db["user-goals"].findOne({ userID: 1, goalID: HC511Goal.goalID });
const userGoal = await db["user-goals"].findOne({ userID: 1, goalID: HC511Goal.goalID });
const secondUpdate = await ProcessGoal(HC511Goal, userGoal!, 1, logger);
const secondUpdate = await ProcessGoal(HC511Goal, userGoal!, 1, logger);
t.equal(secondUpdate, undefined, "Should return undefined.");
t.equal(secondUpdate, undefined, "Should return undefined.");
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -8,65 +8,65 @@ import { RedisPub } from "../../../../external/redis/redis-IPC";
* Update a user's progress on all of their set goals.
*/
export async function GetAndUpdateUsersGoals(
game: Game,
userID: integer,
chartIDs: Set<string>,
logger: KtLogger
game: Game,
userID: integer,
chartIDs: Set<string>,
logger: KtLogger
) {
const { goals, userGoalsMap } = await GetRelevantGoals(game, userID, chartIDs, logger);
const { goals, userGoalsMap } = await GetRelevantGoals(game, userID, chartIDs, logger);
if (!goals.length) {
// if we hit the below code with an empty array mongodb will flip out on the bulkwrite op
return [];
}
if (!goals.length) {
// if we hit the below code with an empty array mongodb will flip out on the bulkwrite op
return [];
}
logger.verbose(`Found ${goals.length} relevant goals.`);
logger.verbose(`Found ${goals.length} relevant goals.`);
return UpdateGoalsForUser(goals, userGoalsMap, userID, logger);
return UpdateGoalsForUser(goals, userGoalsMap, userID, logger);
}
export async function UpdateGoalsForUser(
goals: GoalDocument[],
userGoalsMap: Map<string, UserGoalDocument>,
userID: integer,
logger: KtLogger
goals: GoalDocument[],
userGoalsMap: Map<string, UserGoalDocument>,
userID: integer,
logger: KtLogger
) {
const returns = await Promise.all(
goals.map((goal: GoalDocument) => {
const userGoal = userGoalsMap.get(goal.goalID);
const returns = await Promise.all(
goals.map((goal: GoalDocument) => {
const userGoal = userGoalsMap.get(goal.goalID);
if (!userGoal) {
logger.error(
`UserGoal:GoalID mismatch ${goal.goalID} - this user has no userGoal for this, yet it is set.`
);
return;
}
if (!userGoal) {
logger.error(
`UserGoal:GoalID mismatch ${goal.goalID} - this user has no userGoal for this, yet it is set.`
);
return;
}
return ProcessGoal(goal, userGoal, userID, logger);
})
);
return ProcessGoal(goal, userGoal, userID, logger);
})
);
const importInfo = [];
const bulkWrite = [];
const importInfo = [];
const bulkWrite = [];
for (const ret of returns) {
if (!ret) {
continue;
}
for (const ret of returns) {
if (!ret) {
continue;
}
importInfo.push(ret.import);
bulkWrite.push(ret.bwrite);
}
importInfo.push(ret.import);
bulkWrite.push(ret.bwrite);
}
if (bulkWrite.length === 0) {
// bulkwrite cannot be an empty array -- this means there's nothing to update or return, then.
// i.e. goals was non empty but returns was entirely [undefined, undefined...].
return [];
}
if (bulkWrite.length === 0) {
// bulkwrite cannot be an empty array -- this means there's nothing to update or return, then.
// i.e. goals was non empty but returns was entirely [undefined, undefined...].
return [];
}
await db["user-goals"].bulkWrite(bulkWrite, { ordered: false });
await db["user-goals"].bulkWrite(bulkWrite, { ordered: false });
return importInfo;
return importInfo;
}
/**
@@ -76,73 +76,73 @@ export async function UpdateGoalsForUser(
* to say (i.e. user didnt raise the goal).
*/
export async function ProcessGoal(
goal: GoalDocument,
userGoal: UserGoalDocument,
userID: integer,
logger: KtLogger
goal: GoalDocument,
userGoal: UserGoalDocument,
userID: integer,
logger: KtLogger
) {
const res = await EvaluateGoalForUser(goal, userID, logger);
const res = await EvaluateGoalForUser(goal, userID, logger);
if (!res) {
// some sort of error occured - its logged by the previous function.
return;
}
if (!res) {
// some sort of error occured - its logged by the previous function.
return;
}
// nothing has changed
if (userGoal.progress === res.progress && userGoal.outOf === res.outOf) {
return;
}
// nothing has changed
if (userGoal.progress === res.progress && userGoal.outOf === res.outOf) {
return;
}
const newData = {
progress: res.progress,
progressHuman: res.progressHuman,
outOf: res.outOf,
outOfHuman: res.outOfHuman,
achieved: res.achieved,
};
const newData = {
progress: res.progress,
progressHuman: res.progressHuman,
outOf: res.outOf,
outOfHuman: res.outOfHuman,
achieved: res.achieved,
};
const oldData = {
progress: userGoal.progress,
progressHuman: userGoal.progressHuman,
outOf: userGoal.outOf,
outOfHuman: userGoal.outOfHuman,
achieved: userGoal.achieved,
};
const oldData = {
progress: userGoal.progress,
progressHuman: userGoal.progressHuman,
outOf: userGoal.outOf,
outOfHuman: userGoal.outOfHuman,
achieved: userGoal.achieved,
};
// if this is a newly-achieved goal
if (res.achieved && !userGoal.achieved) {
RedisPub("goal-achieved", {
userID,
goalID: goal.goalID,
old: oldData,
new: newData,
});
}
// if this is a newly-achieved goal
if (res.achieved && !userGoal.achieved) {
RedisPub("goal-achieved", {
userID,
goalID: goal.goalID,
old: oldData,
new: newData,
});
}
const bulkWrite = {
updateOne: {
filter: { _id: userGoal._id! },
update: {
$set: {
...newData,
timeAchieved: newData.achieved ? Date.now() : null,
// we're guaranteed that this works, because things
// that haven't changed return nothing instead of
// getting to this point.
lastInteraction: Date.now(),
},
},
},
};
const bulkWrite = {
updateOne: {
filter: { _id: userGoal._id! },
update: {
$set: {
...newData,
timeAchieved: newData.achieved ? Date.now() : null,
// we're guaranteed that this works, because things
// that haven't changed return nothing instead of
// getting to this point.
lastInteraction: Date.now(),
},
},
},
};
return {
bwrite: bulkWrite,
import: {
goalID: goal.goalID,
old: oldData,
new: newData,
},
};
return {
bwrite: bulkWrite,
import: {
goalID: goal.goalID,
old: oldData,
new: newData,
},
};
}
/**
@@ -154,57 +154,57 @@ export async function ProcessGoal(
* @returns An array of Goals, and an array of userGoals.
*/
export async function GetRelevantGoals(
game: Game,
userID: integer,
chartIDs: Set<string>,
logger: KtLogger
game: Game,
userID: integer,
chartIDs: Set<string>,
logger: KtLogger
): Promise<{ goals: GoalDocument[]; userGoalsMap: Map<string, UserGoalDocument> }> {
const userGoals = await db["user-goals"].find({ game, userID });
const userGoals = await db["user-goals"].find({ game, userID });
logger.verbose(`Found user has ${userGoals.length} goals.`);
logger.verbose(`Found user has ${userGoals.length} goals.`);
if (!userGoals.length) {
return { goals: [], userGoalsMap: new Map() };
}
if (!userGoals.length) {
return { goals: [], userGoalsMap: new Map() };
}
const goalIDs = userGoals.map((e) => e.goalID);
const goalIDs = userGoals.map((e) => e.goalID);
const chartIDsArr: string[] = [];
for (const c of chartIDs) {
chartIDsArr.push(c);
}
const chartIDsArr: string[] = [];
for (const c of chartIDs) {
chartIDsArr.push(c);
}
const goals = await Promise.all([
// this gets the relevantGoals for direct and multi
db.goals.find({
"charts.type": { $in: ["single", "multi"] },
"charts.data": { $in: chartIDsArr },
goalID: { $in: goalIDs },
}),
db.goals.find({
"charts.type": "any",
goalID: { $in: goalIDs },
}),
GetRelevantFolderGoals(goalIDs, chartIDsArr),
]).then((r) => r.flat(1));
const goals = await Promise.all([
// this gets the relevantGoals for direct and multi
db.goals.find({
"charts.type": { $in: ["single", "multi"] },
"charts.data": { $in: chartIDsArr },
goalID: { $in: goalIDs },
}),
db.goals.find({
"charts.type": "any",
goalID: { $in: goalIDs },
}),
GetRelevantFolderGoals(goalIDs, chartIDsArr),
]).then((r) => r.flat(1));
const goalSet = new Set(goals.map((e) => e.goalID));
const goalSet = new Set(goals.map((e) => e.goalID));
const userGoalsMap: Map<string, UserGoalDocument> = new Map();
const userGoalsMap: Map<string, UserGoalDocument> = new Map();
for (const userGoal of userGoals) {
if (!goalSet.has(userGoal.goalID)) {
continue;
}
// since these are guaranteed to be unique, lets make a hot map of goalID -> userGoalDocument, so we can
// pull them in for post-processing and filter out the userGoalDocuments that aren't relevant.
userGoalsMap.set(userGoal.goalID, userGoal);
}
for (const userGoal of userGoals) {
if (!goalSet.has(userGoal.goalID)) {
continue;
}
// since these are guaranteed to be unique, lets make a hot map of goalID -> userGoalDocument, so we can
// pull them in for post-processing and filter out the userGoalDocuments that aren't relevant.
userGoalsMap.set(userGoal.goalID, userGoal);
}
return {
goals,
userGoalsMap,
};
return {
goals,
userGoalsMap,
};
}
/**
@@ -212,34 +212,34 @@ export async function GetRelevantGoals(
* of chartIDsArr.
*/
export function GetRelevantFolderGoals(goalIDs: string[], chartIDsArr: string[]) {
// Slightly black magic - this is kind of like doing an SQL join.
// it's weird to do this in mongodb, but this seems like the right
// way to actually handle this.
// Slightly black magic - this is kind of like doing an SQL join.
// it's weird to do this in mongodb, but this seems like the right
// way to actually handle this.
return db.goals.aggregate([
{
$match: {
"charts.type": "folder",
goalID: { $in: goalIDs },
},
},
{
$lookup: {
from: "folder-chart-lookup",
localField: "charts.data",
foreignField: "folderID",
as: "folderCharts",
},
},
{
$match: {
"folderCharts.chartID": { $in: chartIDsArr },
},
},
{
$project: {
folderCharts: 0,
},
},
]);
return db.goals.aggregate([
{
$match: {
"charts.type": "folder",
goalID: { $in: goalIDs },
},
},
{
$lookup: {
from: "folder-chart-lookup",
localField: "charts.data",
foreignField: "folderID",
as: "folderCharts",
},
},
{
$match: {
"folderCharts.chartID": { $in: chartIDsArr },
},
},
{
$project: {
folderCharts: 0,
},
},
]);
}
@@ -8,27 +8,27 @@ import { integer } from "tachi-common";
* If a lock exists for the user, the lock is returned.
*/
export function GetOrSetUserLock(userID: integer) {
return db["import-locks"].findOneAndUpdate(
{
userID,
},
{
$set: { userID },
},
{
upsert: true,
// this is marked as deprecated, but it shouldn't be, as returnDocument: "before"
// does nothing.
returnOriginal: true,
}
);
return db["import-locks"].findOneAndUpdate(
{
userID,
},
{
$set: { userID },
},
{
upsert: true,
// this is marked as deprecated, but it shouldn't be, as returnDocument: "before"
// does nothing.
returnOriginal: true,
}
);
}
/**
* Removes a users import lock.
*/
export function RemoveUserLock(userID: integer) {
return db["import-locks"].remove({
userID,
});
return db["import-locks"].remove({
userID,
});
}
@@ -11,347 +11,347 @@ import { CloseAllConnections } from "../../../../test-utils/close-connections";
const logger = CreateLogCtx(__filename);
function CreateMockGII(...garr: [string, boolean][]) {
return garr.map((e) => ({
goalID: e[0],
old: {},
new: { achieved: e[1] },
})) as unknown as GoalImportInfo[];
return garr.map((e) => ({
goalID: e[0],
old: {},
new: { achieved: e[1] },
})) as unknown as GoalImportInfo[];
}
t.test("#UpdateUsersMilestones", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(() => db.milestones.insert(TestingIIDXSPMilestone));
t.beforeEach(() =>
db["user-milestones"].insert({
achieved: false,
game: "iidx",
milestoneID: TestingIIDXSPMilestone.milestoneID,
playtype: "SP",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
})
);
t.beforeEach(ResetDBState);
t.beforeEach(() => db.milestones.insert(TestingIIDXSPMilestone));
t.beforeEach(() =>
db["user-milestones"].insert({
achieved: false,
game: "iidx",
milestoneID: TestingIIDXSPMilestone.milestoneID,
playtype: "SP",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
})
);
t.test("Test with clean achieved milestone.", async (t) => {
const res = await UpdateUsersMilestones(
CreateMockGII(
["eg_goal_1", true],
["eg_goal_2", true],
["eg_goal_3", true],
["eg_goal_4", true]
),
"iidx",
["SP"],
1,
logger
);
t.test("Test with clean achieved milestone.", async (t) => {
const res = await UpdateUsersMilestones(
CreateMockGII(
["eg_goal_1", true],
["eg_goal_2", true],
["eg_goal_3", true],
["eg_goal_4", true]
),
"iidx",
["SP"],
1,
logger
);
t.strictSame(
res,
[
{
milestoneID: "example_milestone_id",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
],
"Should correctly assert the milestone is achieved."
);
t.strictSame(
res,
[
{
milestoneID: "example_milestone_id",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
],
"Should correctly assert the milestone is achieved."
);
t.end();
});
t.end();
});
t.test("Test with unclean achieved milestone.", async (t) => {
const res = await UpdateUsersMilestones(
CreateMockGII(
["eg_goal_1", true],
["eg_goal_2", true],
["eg_goal_3", true],
["eg_goal_4", true],
["x_goal_1", true],
["x_goal_2", true],
["x_goal_3", true]
),
"iidx",
["SP"],
1,
logger
);
t.test("Test with unclean achieved milestone.", async (t) => {
const res = await UpdateUsersMilestones(
CreateMockGII(
["eg_goal_1", true],
["eg_goal_2", true],
["eg_goal_3", true],
["eg_goal_4", true],
["x_goal_1", true],
["x_goal_2", true],
["x_goal_3", true]
),
"iidx",
["SP"],
1,
logger
);
t.strictSame(
res,
[
{
milestoneID: "example_milestone_id",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
],
"Should correctly assert the milestone is achieved."
);
t.strictSame(
res,
[
{
milestoneID: "example_milestone_id",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
],
"Should correctly assert the milestone is achieved."
);
t.end();
});
t.end();
});
t.test("Test with increased progress on milestone.", async (t) => {
const res = await UpdateUsersMilestones(
CreateMockGII(["eg_goal_1", true], ["eg_goal_2", true]),
"iidx",
["SP"],
1,
logger
);
t.test("Test with increased progress on milestone.", async (t) => {
const res = await UpdateUsersMilestones(
CreateMockGII(["eg_goal_1", true], ["eg_goal_2", true]),
"iidx",
["SP"],
1,
logger
);
t.strictSame(
res,
[
{
milestoneID: "example_milestone_id",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 2,
achieved: false,
},
},
],
"Should correctly assert the milestone progress has increased."
);
t.strictSame(
res,
[
{
milestoneID: "example_milestone_id",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 2,
achieved: false,
},
},
],
"Should correctly assert the milestone progress has increased."
);
t.end();
});
t.end();
});
t.test("Test with no new goals on milestone.", async (t) => {
const res = await UpdateUsersMilestones([], "iidx", ["SP"], 1, logger);
t.test("Test with no new goals on milestone.", async (t) => {
const res = await UpdateUsersMilestones([], "iidx", ["SP"], 1, logger);
t.strictSame(res, [], "Should correctly return no changes.");
t.strictSame(res, [], "Should correctly return no changes.");
t.end();
});
t.end();
});
t.test("Test with new goals on multiple milestones.", async (t) => {
delete TestingIIDXSPMilestone._id;
await db.milestones.insert(
// @ts-expect-error lol
deepmerge(TestingIIDXSPMilestone, {
milestoneID: "some_other_milestone_with_mutual_goals",
})
);
await db["user-milestones"].insert({
achieved: false,
game: "iidx",
milestoneID: "some_other_milestone_with_mutual_goals",
playtype: "SP",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
});
t.test("Test with new goals on multiple milestones.", async (t) => {
delete TestingIIDXSPMilestone._id;
await db.milestones.insert(
// @ts-expect-error lol
deepmerge(TestingIIDXSPMilestone, {
milestoneID: "some_other_milestone_with_mutual_goals",
})
);
await db["user-milestones"].insert({
achieved: false,
game: "iidx",
milestoneID: "some_other_milestone_with_mutual_goals",
playtype: "SP",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
});
const res = await UpdateUsersMilestones(
CreateMockGII(
["eg_goal_1", true],
["eg_goal_2", true],
["eg_goal_3", true],
["eg_goal_4", true],
["x_goal_1", true],
["x_goal_2", true],
["x_goal_3", true]
),
"iidx",
["SP", "DP"],
1,
logger
);
const res = await UpdateUsersMilestones(
CreateMockGII(
["eg_goal_1", true],
["eg_goal_2", true],
["eg_goal_3", true],
["eg_goal_4", true],
["x_goal_1", true],
["x_goal_2", true],
["x_goal_3", true]
),
"iidx",
["SP", "DP"],
1,
logger
);
t.strictSame(
res,
[
{
milestoneID: "example_milestone_id",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
{
milestoneID: "some_other_milestone_with_mutual_goals",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
],
"Should correctly assert the milestones are achieved."
);
t.end();
});
t.strictSame(
res,
[
{
milestoneID: "example_milestone_id",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
{
milestoneID: "some_other_milestone_with_mutual_goals",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
],
"Should correctly assert the milestones are achieved."
);
t.end();
});
t.test("Test with multiple milestones that only some match", async (t) => {
delete TestingIIDXSPMilestone._id;
await db.milestones.insert([
// @ts-expect-error lol
deepmerge(TestingIIDXSPMilestone, {
milestoneID: "some_other_milestone_with_mutual_goals",
}),
deepmerge(
TestingIIDXSPMilestone,
{
milestoneID: "dp_milestone",
playtype: "DP",
milestoneData: [
{
goals: [{ goalID: "foobar" }],
},
],
},
{ arrayMerge: (d, s) => s }
),
deepmerge(
TestingIIDXSPMilestone,
{
milestoneID: "other_game_milestone",
game: "museca",
playtype: "Single",
milestoneData: [
{
goals: [{ goalID: "foo" }],
},
],
},
{ arrayMerge: (d, s) => s }
),
deepmerge(
TestingIIDXSPMilestone,
{
milestoneID: "iidx_with_not_goal",
game: "iidx",
playtype: "SP",
milestoneData: [
{
goals: [{ goalID: "not_real_goal_id" }],
},
],
},
{ arrayMerge: (d, s) => s }
),
]);
t.test("Test with multiple milestones that only some match", async (t) => {
delete TestingIIDXSPMilestone._id;
await db.milestones.insert([
// @ts-expect-error lol
deepmerge(TestingIIDXSPMilestone, {
milestoneID: "some_other_milestone_with_mutual_goals",
}),
deepmerge(
TestingIIDXSPMilestone,
{
milestoneID: "dp_milestone",
playtype: "DP",
milestoneData: [
{
goals: [{ goalID: "foobar" }],
},
],
},
{ arrayMerge: (d, s) => s }
),
deepmerge(
TestingIIDXSPMilestone,
{
milestoneID: "other_game_milestone",
game: "museca",
playtype: "Single",
milestoneData: [
{
goals: [{ goalID: "foo" }],
},
],
},
{ arrayMerge: (d, s) => s }
),
deepmerge(
TestingIIDXSPMilestone,
{
milestoneID: "iidx_with_not_goal",
game: "iidx",
playtype: "SP",
milestoneData: [
{
goals: [{ goalID: "not_real_goal_id" }],
},
],
},
{ arrayMerge: (d, s) => s }
),
]);
await db["user-milestones"].insert([
{
achieved: false,
game: "iidx",
milestoneID: "some_other_milestone_with_mutual_goals",
playtype: "SP",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
},
{
achieved: false,
game: "iidx",
milestoneID: "dp_milestone",
playtype: "DP",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
},
{
achieved: false,
game: "museca",
milestoneID: "other_game_milestone",
playtype: "Single",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
},
{
achieved: false,
game: "iidx",
milestoneID: "iidx_with_not_goal",
playtype: "SP",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
},
]);
await db["user-milestones"].insert([
{
achieved: false,
game: "iidx",
milestoneID: "some_other_milestone_with_mutual_goals",
playtype: "SP",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
},
{
achieved: false,
game: "iidx",
milestoneID: "dp_milestone",
playtype: "DP",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
},
{
achieved: false,
game: "museca",
milestoneID: "other_game_milestone",
playtype: "Single",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
},
{
achieved: false,
game: "iidx",
milestoneID: "iidx_with_not_goal",
playtype: "SP",
progress: 0,
timeAchieved: null,
timeSet: 0,
userID: 1,
},
]);
const res = await UpdateUsersMilestones(
CreateMockGII(
["eg_goal_1", true],
["eg_goal_2", true],
["eg_goal_3", true],
["eg_goal_4", true],
["x_goal_1", true],
["x_goal_2", true],
["x_goal_3", true]
),
"iidx",
["SP", "DP"],
1,
logger
);
const res = await UpdateUsersMilestones(
CreateMockGII(
["eg_goal_1", true],
["eg_goal_2", true],
["eg_goal_3", true],
["eg_goal_4", true],
["x_goal_1", true],
["x_goal_2", true],
["x_goal_3", true]
),
"iidx",
["SP", "DP"],
1,
logger
);
t.strictSame(
res,
[
{
milestoneID: "example_milestone_id",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
{
milestoneID: "some_other_milestone_with_mutual_goals",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
],
"Should correctly assert the milestones are achieved."
);
t.end();
});
t.strictSame(
res,
[
{
milestoneID: "example_milestone_id",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
{
milestoneID: "some_other_milestone_with_mutual_goals",
old: {
progress: 0,
achieved: false,
},
new: {
progress: 4,
achieved: true,
},
},
],
"Should correctly assert the milestones are achieved."
);
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -1,12 +1,12 @@
import { KtLogger } from "../../../logger/logger";
import {
integer,
Game,
Playtypes,
GoalImportInfo,
UserMilestoneDocument,
MilestoneImportInfo,
MilestoneDocument,
integer,
Game,
Playtypes,
GoalImportInfo,
UserMilestoneDocument,
MilestoneImportInfo,
MilestoneDocument,
} from "tachi-common";
import db from "../../../../external/mongo/db";
import { BulkWriteUpdateOneOperation } from "mongodb";
@@ -18,154 +18,154 @@ import { RedisPub, RedisSub } from "../../../../external/redis/redis-IPC";
* about goals from imports)
*/
export function ProcessMilestoneFromGII(
milestone: MilestoneDocument,
gii: Map<string, GoalImportInfo["new"]>
milestone: MilestoneDocument,
gii: Map<string, GoalImportInfo["new"]>
) {
const goalIDs = GetGoalIDsFromMilestone(milestone);
const goalIDs = GetGoalIDsFromMilestone(milestone);
let progress = 0;
let progress = 0;
for (const goalID of goalIDs) {
const userInfo = gii.get(goalID);
for (const goalID of goalIDs) {
const userInfo = gii.get(goalID);
if (!userInfo) {
continue;
}
if (!userInfo) {
continue;
}
if (!userInfo.achieved) {
continue;
}
if (!userInfo.achieved) {
continue;
}
progress++;
}
progress++;
}
const outOf = CalculateMilestoneOutOf(milestone, goalIDs);
const outOf = CalculateMilestoneOutOf(milestone, goalIDs);
// milestone achieved!
if (progress >= outOf) {
return { achieved: true, progress };
}
// milestone achieved!
if (progress >= outOf) {
return { achieved: true, progress };
}
return { achieved: false, progress };
return { achieved: false, progress };
}
export async function UpdateUsersMilestones(
importGoalInfo: GoalImportInfo[],
game: Game,
playtypes: Playtypes[Game][],
userID: integer,
logger: KtLogger
importGoalInfo: GoalImportInfo[],
game: Game,
playtypes: Playtypes[Game][],
userID: integer,
logger: KtLogger
) {
const userGoalInfoMap: Map<string, GoalImportInfo["new"]> = new Map();
const userGoalInfoMap: Map<string, GoalImportInfo["new"]> = new Map();
const goalIDs = [];
for (const e of importGoalInfo) {
userGoalInfoMap.set(e.goalID, e.new);
goalIDs.push(e.goalID);
}
const goalIDs = [];
for (const e of importGoalInfo) {
userGoalInfoMap.set(e.goalID, e.new);
goalIDs.push(e.goalID);
}
const { milestones, userMilestones } = await GetRelevantMilestones(
goalIDs,
game,
playtypes,
userID,
logger
);
const { milestones, userMilestones } = await GetRelevantMilestones(
goalIDs,
game,
playtypes,
userID,
logger
);
// create a map here to avoid linear searching when
// co-iterating
const userMilestoneMap = new Map();
for (const um of userMilestones) {
userMilestoneMap.set(um.milestoneID, um);
}
// create a map here to avoid linear searching when
// co-iterating
const userMilestoneMap = new Map();
for (const um of userMilestones) {
userMilestoneMap.set(um.milestoneID, um);
}
const importGoalMap = new Map();
const importGoalMap = new Map();
for (const ig of importGoalInfo) {
importGoalMap.set(ig.goalID, ig.new);
}
for (const ig of importGoalInfo) {
importGoalMap.set(ig.goalID, ig.new);
}
const bwrite: BulkWriteUpdateOneOperation<UserMilestoneDocument>[] = [];
const bwrite: BulkWriteUpdateOneOperation<UserMilestoneDocument>[] = [];
const importMilestoneInfo: MilestoneImportInfo[] = [];
const importMilestoneInfo: MilestoneImportInfo[] = [];
for (const milestone of milestones) {
const { achieved, progress } = ProcessMilestoneFromGII(milestone, importGoalMap);
for (const milestone of milestones) {
const { achieved, progress } = ProcessMilestoneFromGII(milestone, importGoalMap);
const userMilestone = userMilestoneMap.get(milestone.milestoneID);
const userMilestone = userMilestoneMap.get(milestone.milestoneID);
if (!userMilestone) {
logger.severe(
`Invalid state achieved in milestone processing - processed milestone that user did not have? ${milestone.milestoneID}`
);
if (!userMilestone) {
logger.severe(
`Invalid state achieved in milestone processing - processed milestone that user did not have? ${milestone.milestoneID}`
);
throw new Error("Invalid state achieved in milestone processing.");
}
throw new Error("Invalid state achieved in milestone processing.");
}
bwrite.push({
updateOne: {
filter: { milestoneID: milestone.milestoneID },
update: {
$set: {
achieved,
progress,
},
},
},
});
bwrite.push({
updateOne: {
filter: { milestoneID: milestone.milestoneID },
update: {
$set: {
achieved,
progress,
},
},
},
});
const milestoneInfo = {
milestoneID: userMilestone.milestoneID,
old: {
progress: userMilestone.progress,
achieved: userMilestone.achieved,
},
new: {
progress,
achieved,
},
};
const milestoneInfo = {
milestoneID: userMilestone.milestoneID,
old: {
progress: userMilestone.progress,
achieved: userMilestone.achieved,
},
new: {
progress,
achieved,
},
};
if (progress !== userMilestone.progress) {
importMilestoneInfo.push(milestoneInfo);
}
if (progress !== userMilestone.progress) {
importMilestoneInfo.push(milestoneInfo);
}
if (achieved && !userMilestone.achieved) {
RedisPub("milestone-achieved", {
userID,
...milestoneInfo,
});
}
}
if (achieved && !userMilestone.achieved) {
RedisPub("milestone-achieved", {
userID,
...milestoneInfo,
});
}
}
if (bwrite.length !== 0) {
await db["user-milestones"].bulkWrite(bwrite, { ordered: false });
}
if (bwrite.length !== 0) {
await db["user-milestones"].bulkWrite(bwrite, { ordered: false });
}
return importMilestoneInfo;
return importMilestoneInfo;
}
async function GetRelevantMilestones(
goalIDs: string[],
game: Game,
playtypes: Playtypes[Game][],
userID: integer,
logger: KtLogger
goalIDs: string[],
game: Game,
playtypes: Playtypes[Game][],
userID: integer,
logger: KtLogger
) {
const userMilestones = await db["user-milestones"].find({
game,
playtype: { $in: playtypes },
userID,
});
const userMilestones = await db["user-milestones"].find({
game,
playtype: { $in: playtypes },
userID,
});
logger.debug(`Found ${userMilestones.length} user-milestones.`);
logger.debug(`Found ${userMilestones.length} user-milestones.`);
const milestones = await db.milestones.find({
milestoneID: { $in: userMilestones.map((e) => e.milestoneID) },
"milestoneData.goals.goalID": { $in: goalIDs },
});
const milestones = await db.milestones.find({
milestoneID: { $in: userMilestones.map((e) => e.milestoneID) },
"milestoneData.goals.goalID": { $in: goalIDs },
});
logger.debug(`Found ${milestones.length} relevant milestones.`);
logger.debug(`Found ${milestones.length} relevant milestones.`);
return { userMilestones, milestones };
return { userMilestones, milestones };
}
@@ -12,242 +12,242 @@ import deepmerge from "deepmerge";
const logger = CreateLogCtx(__filename);
const batchManualScore: BatchManualScore = {
score: 500,
lamp: "HARD CLEAR",
matchType: "songTitle",
identifier: "5.1.1.",
playtype: "SP",
difficulty: "ANOTHER",
score: 500,
lamp: "HARD CLEAR",
matchType: "songTitle",
identifier: "5.1.1.",
playtype: "SP",
difficulty: "ANOTHER",
};
const batchManualContext: BatchManualContext = { game: "iidx", service: "foo", version: "27" };
t.test("#OrphanScore", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should orphan data and context information.", async (t) => {
const res = await OrphanScore(
"ir/direct-manual",
1,
batchManualScore,
batchManualContext,
"Example Error Message",
logger
);
t.test("Should orphan data and context information.", async (t) => {
const res = await OrphanScore(
"ir/direct-manual",
1,
batchManualScore,
batchManualContext,
"Example Error Message",
logger
);
t.equal(res.success, true);
t.equal(
res.orphanID,
`O${fjsh.hash(
{
importType: "ir/direct-manual",
data: batchManualScore,
context: batchManualContext,
userID: 1,
},
"sha256"
)}`,
"Should be a checksum of the orphan's contents."
);
t.equal(res.success, true);
t.equal(
res.orphanID,
`O${fjsh.hash(
{
importType: "ir/direct-manual",
data: batchManualScore,
context: batchManualContext,
userID: 1,
},
"sha256"
)}`,
"Should be a checksum of the orphan's contents."
);
const dbCheck = await db["orphan-scores"].findOne({
orphanID: res.orphanID,
});
const dbCheck = await db["orphan-scores"].findOne({
orphanID: res.orphanID,
});
t.hasStrict(dbCheck, {
orphanID: res.orphanID,
userID: 1,
data: batchManualScore,
context: batchManualContext,
importType: "ir/direct-manual",
errMsg: "Example Error Message",
});
t.hasStrict(dbCheck, {
orphanID: res.orphanID,
userID: 1,
data: batchManualScore,
context: batchManualContext,
importType: "ir/direct-manual",
errMsg: "Example Error Message",
});
t.ok(
Math.abs(dbCheck!.timeInserted - Date.now()) < 10_000,
"timeInserted should be decently close to now."
);
t.ok(
Math.abs(dbCheck!.timeInserted - Date.now()) < 10_000,
"timeInserted should be decently close to now."
);
t.end();
});
t.end();
});
t.test("Should skip orphans that already exist.", async (t) => {
const res1 = await OrphanScore(
"ir/direct-manual",
1,
batchManualScore,
batchManualContext,
"Example Error Message",
logger
);
t.test("Should skip orphans that already exist.", async (t) => {
const res1 = await OrphanScore(
"ir/direct-manual",
1,
batchManualScore,
batchManualContext,
"Example Error Message",
logger
);
const res2 = await OrphanScore(
"ir/direct-manual",
1,
batchManualScore,
batchManualContext,
"Example Error Message",
logger
);
const res2 = await OrphanScore(
"ir/direct-manual",
1,
batchManualScore,
batchManualContext,
"Example Error Message",
logger
);
t.equal(res2.success, false);
t.equal(res1.orphanID, res2.orphanID);
t.equal(res2.success, false);
t.equal(res1.orphanID, res2.orphanID);
const dbCheck = await db["orphan-scores"].count();
const dbCheck = await db["orphan-scores"].count();
t.equal(dbCheck, 1);
});
t.equal(dbCheck, 1);
});
t.end();
t.end();
});
t.test("#ReprocessOrphan", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
const orphanDoc: OrphanScoreDocument = {
context: batchManualContext,
data: batchManualScore,
errMsg: "foo",
importType: "ir/direct-manual",
orphanID: "foo",
timeInserted: 0,
userID: 1,
};
const orphanDoc: OrphanScoreDocument = {
context: batchManualContext,
data: batchManualScore,
errMsg: "foo",
importType: "ir/direct-manual",
orphanID: "foo",
timeInserted: 0,
userID: 1,
};
t.test("Should convert and insert an orphan if parents are found", async (t) => {
// this orphan doc is for 5.1.1. SPA, which definitely exists in
// the test DB.
const res = await ReprocessOrphan(orphanDoc, logger);
t.test("Should convert and insert an orphan if parents are found", async (t) => {
// this orphan doc is for 5.1.1. SPA, which definitely exists in
// the test DB.
const res = await ReprocessOrphan(orphanDoc, logger);
t.hasStrict(
res,
{
success: true,
type: "ScoreImported",
message: null,
content: {
score: {
game: "iidx",
service: "foo (DIRECT-MANUAL)",
comment: null,
importType: "ir/direct-manual",
timeAchieved: null,
scoreMeta: {},
scoreData: {
lampIndex: 5,
gradeIndex: 1,
esd: 60.546875,
lamp: "HARD CLEAR",
score: 500,
grade: "E",
percent: 31.806615776081426,
hitData: {},
hitMeta: {},
},
highlight: false,
userID: 1,
calculatedData: {
ktRating: 0.6840944877852578,
ktLampRating: 10.6,
BPI: -15,
"K%": null,
},
songID: 1,
chartID: "c2311194e3897ddb5745b1760d2c0141f933e683",
scoreID:
"R7a3a2b04bd4882ec06c198d78297fe3d56561502c7b134c067214bbfdf4f1602",
playtype: "SP",
isPrimary: true,
},
},
},
"Should successfully import the score."
);
t.hasStrict(
res,
{
success: true,
type: "ScoreImported",
message: null,
content: {
score: {
game: "iidx",
service: "foo (DIRECT-MANUAL)",
comment: null,
importType: "ir/direct-manual",
timeAchieved: null,
scoreMeta: {},
scoreData: {
lampIndex: 5,
gradeIndex: 1,
esd: 60.546875,
lamp: "HARD CLEAR",
score: 500,
grade: "E",
percent: 31.806615776081426,
hitData: {},
hitMeta: {},
},
highlight: false,
userID: 1,
calculatedData: {
ktRating: 0.6840944877852578,
ktLampRating: 10.6,
BPI: -15,
"K%": null,
},
songID: 1,
chartID: "c2311194e3897ddb5745b1760d2c0141f933e683",
scoreID:
"R7a3a2b04bd4882ec06c198d78297fe3d56561502c7b134c067214bbfdf4f1602",
playtype: "SP",
isPrimary: true,
},
},
},
"Should successfully import the score."
);
const orphan = await db["orphan-scores"].findOne({ orphanID: orphanDoc.orphanID });
const orphan = await db["orphan-scores"].findOne({ orphanID: orphanDoc.orphanID });
t.equal(orphan, null, "Should remove the orphan document.");
t.equal(orphan, null, "Should remove the orphan document.");
t.end();
});
t.end();
});
t.test("Should delete the orphan doc and return null if the score is invalid.", async (t) => {
await db["orphan-scores"].insert(orphanDoc);
const res = await ReprocessOrphan(
deepmerge(orphanDoc, {
data: {
score: 99999,
},
}),
logger
);
t.test("Should delete the orphan doc and return null if the score is invalid.", async (t) => {
await db["orphan-scores"].insert(orphanDoc);
const res = await ReprocessOrphan(
deepmerge(orphanDoc, {
data: {
score: 99999,
},
}),
logger
);
t.equal(res, null);
t.equal(res, null);
const orphan = await db["orphan-scores"].findOne({ orphanID: orphanDoc.orphanID });
const orphan = await db["orphan-scores"].findOne({ orphanID: orphanDoc.orphanID });
t.equal(orphan, null, "Should remove the orphan document.");
t.equal(orphan, null, "Should remove the orphan document.");
t.end();
});
t.end();
});
t.test(
"Should keep the orphan doc and return false if no parents could be found.",
async (t) => {
await db["orphan-scores"].insert(orphanDoc);
const res = await ReprocessOrphan(
deepmerge(orphanDoc, {
data: {
identifier: "NONSENSE CHART TITLE",
},
}),
logger
);
t.test(
"Should keep the orphan doc and return false if no parents could be found.",
async (t) => {
await db["orphan-scores"].insert(orphanDoc);
const res = await ReprocessOrphan(
deepmerge(orphanDoc, {
data: {
identifier: "NONSENSE CHART TITLE",
},
}),
logger
);
t.equal(res, false);
t.equal(res, false);
const orphan = await db["orphan-scores"].findOne({ orphanID: orphanDoc.orphanID });
const orphan = await db["orphan-scores"].findOne({ orphanID: orphanDoc.orphanID });
t.not(orphan, null, "Should not remove the orphan document.");
t.not(orphan, null, "Should not remove the orphan document.");
t.end();
}
);
t.end();
}
);
t.test(
"Should keep the orphan doc and return false if an internal failure was triggered.",
async (t) => {
await db["orphan-scores"].insert(orphanDoc);
const GAZER_SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d";
t.test(
"Should keep the orphan doc and return false if an internal failure was triggered.",
async (t) => {
await db["orphan-scores"].insert(orphanDoc);
const GAZER_SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d";
// force an internal failure by removing all bms songs and then
// leaving a valid bms chart to match with.
await db.songs.bms.remove({});
// force an internal failure by removing all bms songs and then
// leaving a valid bms chart to match with.
await db.songs.bms.remove({});
const res = await ReprocessOrphan(
deepmerge(orphanDoc, {
context: {
game: "bms",
},
data: {
matchType: "bmsChartHash",
identifier: GAZER_SHA256,
},
}),
logger
);
const res = await ReprocessOrphan(
deepmerge(orphanDoc, {
context: {
game: "bms",
},
data: {
matchType: "bmsChartHash",
identifier: GAZER_SHA256,
},
}),
logger
);
t.equal(res, false);
t.equal(res, false);
const orphan = await db["orphan-scores"].findOne({ orphanID: orphanDoc.orphanID });
const orphan = await db["orphan-scores"].findOne({ orphanID: orphanDoc.orphanID });
t.not(orphan, null, "Should not remove the orphan document.");
t.not(orphan, null, "Should not remove the orphan document.");
t.end();
}
);
t.end();
}
);
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -1,19 +1,19 @@
import db from "../../../../external/mongo/db";
import {
ConverterFunction,
ConverterFnReturnOrFailure,
ImportTypeContextMap,
ImportTypeDataMap,
OrphanScoreDocument,
ConverterFunction,
ConverterFnReturnOrFailure,
ImportTypeContextMap,
ImportTypeDataMap,
OrphanScoreDocument,
} from "../../import-types/common/types";
import { ImportTypes, integer } from "tachi-common";
import fjsh from "fast-json-stable-hash";
import { KtLogger } from "../../../logger/logger";
import { Converters } from "../../import-types/converters";
import {
ConverterFailure,
InternalFailure,
KTDataNotFoundFailure,
ConverterFailure,
InternalFailure,
KTDataNotFoundFailure,
} from "../common/converter-failures";
import { ProcessSuccessfulConverterReturn } from "../score-importing/score-importing";
@@ -24,39 +24,39 @@ import { ProcessSuccessfulConverterReturn } from "../score-importing/score-impor
* @returns Returns { success: true | false, orphanID }
*/
export async function OrphanScore<T extends ImportTypes = ImportTypes>(
importType: T,
userID: integer,
data: ImportTypeDataMap[T],
context: ImportTypeContextMap[T],
errMsg: string | null,
logger: KtLogger
importType: T,
userID: integer,
data: ImportTypeDataMap[T],
context: ImportTypeContextMap[T],
errMsg: string | null,
logger: KtLogger
) {
const orphan: Pick<OrphanScoreDocument, "importType" | "data" | "context" | "userID"> = {
importType,
data,
context: context,
userID,
};
const orphan: Pick<OrphanScoreDocument, "importType" | "data" | "context" | "userID"> = {
importType,
data,
context: context,
userID,
};
const orphanID = `O${fjsh.hash(orphan, "sha256")}`;
const orphanID = `O${fjsh.hash(orphan, "sha256")}`;
const exists = await db["orphan-scores"].findOne({ orphanID });
const exists = await db["orphan-scores"].findOne({ orphanID });
if (exists) {
logger.debug(`Skipped orphaning score ${orphanID} because it already exists.`);
return { success: false, orphanID };
}
if (exists) {
logger.debug(`Skipped orphaning score ${orphanID} because it already exists.`);
return { success: false, orphanID };
}
const orphanScoreDoc: OrphanScoreDocument = {
...orphan,
orphanID,
errMsg,
timeInserted: Date.now(),
};
const orphanScoreDoc: OrphanScoreDocument = {
...orphan,
orphanID,
errMsg,
timeInserted: Date.now(),
};
await db["orphan-scores"].insert(orphanScoreDoc);
await db["orphan-scores"].insert(orphanScoreDoc);
return { success: true, orphanID };
return { success: true, orphanID };
}
/**
@@ -68,50 +68,50 @@ export async function OrphanScore<T extends ImportTypes = ImportTypes>(
* ImportProcessingInfo on success.
*/
export async function ReprocessOrphan(orphan: OrphanScoreDocument, logger: KtLogger) {
const ConverterFunction = Converters[orphan.importType] as ConverterFunction<
ImportTypeDataMap[ImportTypes],
ImportTypeContextMap[ImportTypes]
>;
const ConverterFunction = Converters[orphan.importType] as ConverterFunction<
ImportTypeDataMap[ImportTypes],
ImportTypeContextMap[ImportTypes]
>;
let res: ConverterFnReturnOrFailure;
let res: ConverterFnReturnOrFailure;
try {
res = await ConverterFunction(orphan.data, orphan.context, orphan.importType, logger);
} catch (err) {
// this is impossible to test, so we're going to ignore it
/* istanbul ignore next */
if (!(err instanceof ConverterFailure)) {
logger.error(`Converter function ${orphan.importType} returned unexpected error.`, {
err,
});
throw err; // throw this higher up, i guess.
}
try {
res = await ConverterFunction(orphan.data, orphan.context, orphan.importType, logger);
} catch (err) {
// this is impossible to test, so we're going to ignore it
/* istanbul ignore next */
if (!(err instanceof ConverterFailure)) {
logger.error(`Converter function ${orphan.importType} returned unexpected error.`, {
err,
});
throw err; // throw this higher up, i guess.
}
res = err;
}
res = err;
}
// If the data still can't be found, we do nothing about it.
if (res instanceof KTDataNotFoundFailure) {
logger.debug(`Unorphaning ${orphan.orphanID} failed. (${res.message})`);
return false;
} else if (res instanceof InternalFailure) {
logger.error(`Orphan Internal Failure - ${res.message}, OrphanID ${orphan.orphanID}`);
// If the data still can't be found, we do nothing about it.
if (res instanceof KTDataNotFoundFailure) {
logger.debug(`Unorphaning ${orphan.orphanID} failed. (${res.message})`);
return false;
} else if (res instanceof InternalFailure) {
logger.error(`Orphan Internal Failure - ${res.message}, OrphanID ${orphan.orphanID}`);
return false;
} else if (res instanceof ConverterFailure) {
logger.warn(
`Recieved ConverterFailure ${res.message} on orphan ${orphan.orphanID}. Removing orphan.`
);
return false;
} else if (res instanceof ConverterFailure) {
logger.warn(
`Recieved ConverterFailure ${res.message} on orphan ${orphan.orphanID}. Removing orphan.`
);
// @danger - This could go terribly, if there's a mistake in the converterFN we might accidentally
// remove a users score.
await db["orphan-scores"].remove({ orphanID: orphan.orphanID });
// @danger - This could go terribly, if there's a mistake in the converterFN we might accidentally
// remove a users score.
await db["orphan-scores"].remove({ orphanID: orphan.orphanID });
return null;
}
return null;
}
await db["orphan-scores"].remove({ orphanID: orphan.orphanID });
await db["orphan-scores"].remove({ orphanID: orphan.orphanID });
// else, import the orphan.
return ProcessSuccessfulConverterReturn(orphan.userID, res, logger);
// else, import the orphan.
return ProcessSuccessfulConverterReturn(orphan.userID, res, logger);
}
@@ -14,159 +14,159 @@ let IIDXScore = TestingIIDXSPScore;
const logger = CreateLogCtx(__filename);
t.test("#CreatePBDoc", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(() => {
// monk adds _id onto the file when you import it, so lets try and avoid that
IIDXScore = GetKTDataJSON("./tachi/iidx-score.json");
});
t.beforeEach(ResetDBState);
t.beforeEach(() => {
// monk adds _id onto the file when you import it, so lets try and avoid that
IIDXScore = GetKTDataJSON("./tachi/iidx-score.json");
});
const chartID = Testing511SPA.chartID;
const chartID = Testing511SPA.chartID;
const ExamplePBDoc = {
chartID,
userID: 1,
songID: 1,
// rankingInfo -- is not present because it is not added until post-processing.
highlight: false,
isPrimary: true,
timeAchieved: 1619454485988,
game: "iidx",
playtype: "SP",
comments: [],
composedFrom: {
scorePB: IIDXScore.scoreID,
lampPB: "LAMP_PB_ID",
},
scoreData: {
score: IIDXScore.scoreData.score,
percent: IIDXScore.scoreData.percent,
esd: IIDXScore.scoreData.esd,
grade: IIDXScore.scoreData.grade,
gradeIndex: IIDXScore.scoreData.gradeIndex,
hitData: IIDXScore.scoreData.hitData,
lamp: "FULL COMBO",
lampIndex: lamps.iidx.indexOf("FULL COMBO"),
hitMeta: { bp: 1 },
},
calculatedData: {
ktRating: IIDXScore.calculatedData.ktRating,
ktLampRating: 12,
},
};
const ExamplePBDoc = {
chartID,
userID: 1,
songID: 1,
// rankingInfo -- is not present because it is not added until post-processing.
highlight: false,
isPrimary: true,
timeAchieved: 1619454485988,
game: "iidx",
playtype: "SP",
comments: [],
composedFrom: {
scorePB: IIDXScore.scoreID,
lampPB: "LAMP_PB_ID",
},
scoreData: {
score: IIDXScore.scoreData.score,
percent: IIDXScore.scoreData.percent,
esd: IIDXScore.scoreData.esd,
grade: IIDXScore.scoreData.grade,
gradeIndex: IIDXScore.scoreData.gradeIndex,
hitData: IIDXScore.scoreData.hitData,
lamp: "FULL COMBO",
lampIndex: lamps.iidx.indexOf("FULL COMBO"),
hitMeta: { bp: 1 },
},
calculatedData: {
ktRating: IIDXScore.calculatedData.ktRating,
ktLampRating: 12,
},
};
t.test(
"(IIDX) Should use the GameSpecificMergeFN to also join the BP PB if necessary.",
async (t) => {
await db.scores.remove({});
await db.scores.insert([
IIDXScore,
deepmerge(IIDXScore, {
scoreData: {
lamp: "FULL COMBO",
lampIndex: lamps.iidx.indexOf("FULL COMBO"),
score: 0,
percent: 0,
hitMeta: {
bp: 15,
},
},
calculatedData: {
ktLampRating: 12,
},
scoreID: "LAMP_PB_ID",
}),
deepmerge(IIDXScore, {
scoreData: {
lamp: "CLEAR",
lampIndex: lamps.iidx.indexOf("CLEAR"),
score: 1,
percent: 1,
hitMeta: {
bp: 5,
},
},
calculatedData: {
ktLampRating: 10,
},
scoreID: "BP_PB_ID",
}),
]);
t.test(
"(IIDX) Should use the GameSpecificMergeFN to also join the BP PB if necessary.",
async (t) => {
await db.scores.remove({});
await db.scores.insert([
IIDXScore,
deepmerge(IIDXScore, {
scoreData: {
lamp: "FULL COMBO",
lampIndex: lamps.iidx.indexOf("FULL COMBO"),
score: 0,
percent: 0,
hitMeta: {
bp: 15,
},
},
calculatedData: {
ktLampRating: 12,
},
scoreID: "LAMP_PB_ID",
}),
deepmerge(IIDXScore, {
scoreData: {
lamp: "CLEAR",
lampIndex: lamps.iidx.indexOf("CLEAR"),
score: 1,
percent: 1,
hitMeta: {
bp: 5,
},
},
calculatedData: {
ktLampRating: 10,
},
scoreID: "BP_PB_ID",
}),
]);
const res = await CreatePBDoc(1, chartID, logger);
const res = await CreatePBDoc(1, chartID, logger);
t.not(res, undefined, "Should actually return something.");
t.not(res, undefined, "Should actually return something.");
t.strictSame(
res,
deepmerge(ExamplePBDoc, {
composedFrom: {
other: [{ name: "Best BP", scoreID: "BP_PB_ID" }],
},
scoreData: {
hitMeta: {
bp: 5,
gsm: null,
gauge: null,
gaugeHistory: null,
},
},
}),
"Should correctly return a merged PBDocument with BP"
);
t.strictSame(
res,
deepmerge(ExamplePBDoc, {
composedFrom: {
other: [{ name: "Best BP", scoreID: "BP_PB_ID" }],
},
scoreData: {
hitMeta: {
bp: 5,
gsm: null,
gauge: null,
gaugeHistory: null,
},
},
}),
"Should correctly return a merged PBDocument with BP"
);
t.end();
}
);
t.end();
}
);
t.test("Should merge a score and lamp PB into one document.", async (t) => {
const d = deepmerge(IIDXScore, {
scoreData: {
lamp: "FULL COMBO",
lampIndex: lamps.iidx.indexOf("FULL COMBO"),
score: 0,
percent: 0,
hitMeta: {
bp: 1,
},
},
calculatedData: {
ktLampRating: 12,
},
scoreID: "LAMP_PB_ID",
});
t.test("Should merge a score and lamp PB into one document.", async (t) => {
const d = deepmerge(IIDXScore, {
scoreData: {
lamp: "FULL COMBO",
lampIndex: lamps.iidx.indexOf("FULL COMBO"),
score: 0,
percent: 0,
hitMeta: {
bp: 1,
},
},
calculatedData: {
ktLampRating: 12,
},
scoreID: "LAMP_PB_ID",
});
await db.scores.remove({});
await db.scores.insert([IIDXScore, d]);
await db.scores.remove({});
await db.scores.insert([IIDXScore, d]);
const res = await CreatePBDoc(1, chartID, logger);
const res = await CreatePBDoc(1, chartID, logger);
t.not(res, undefined, "Should actually return something.");
t.not(res, undefined, "Should actually return something.");
t.strictSame(res, ExamplePBDoc, "Should correctly return a merged PBDocument");
t.strictSame(res, ExamplePBDoc, "Should correctly return a merged PBDocument");
t.end();
});
t.end();
});
t.test("Should bail safely if no score exists when one should", async (t) => {
// a work of genius
const fakeLogger = {
severe: () => (severeCalled = true),
} as unknown as KtLogger;
t.test("Should bail safely if no score exists when one should", async (t) => {
// a work of genius
const fakeLogger = {
severe: () => (severeCalled = true),
} as unknown as KtLogger;
let severeCalled = false;
let severeCalled = false;
await db.scores.remove({});
await db.scores.remove({});
const res = await CreatePBDoc(1, chartID, fakeLogger);
const res = await CreatePBDoc(1, chartID, fakeLogger);
t.equal(res, undefined, "Should return nothing (and emit a warning)");
t.equal(res, undefined, "Should return nothing (and emit a warning)");
t.equal(severeCalled, true, "Severe logging should have been called.");
t.equal(severeCalled, true, "Severe logging should have been called.");
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -7,89 +7,89 @@ import { BulkWriteUpdateOneOperation } from "mongodb";
export type PBScoreDocumentNoRank = Omit<PBScoreDocument, "rankingData">;
export async function CreatePBDoc(userID: integer, chartID: string, logger: KtLogger) {
const scorePB = await db.scores.findOne(
{
userID,
chartID,
},
{
sort: {
"scoreData.percent": -1,
},
}
);
const scorePB = await db.scores.findOne(
{
userID,
chartID,
},
{
sort: {
"scoreData.percent": -1,
},
}
);
if (!scorePB) {
logger.severe(`User has no scores on chart, but a PB was attempted to be created?`, {
chartID,
userID,
});
return; // ??
}
if (!scorePB) {
logger.severe(`User has no scores on chart, but a PB was attempted to be created?`, {
chartID,
userID,
});
return; // ??
}
const lampPB = (await db.scores.findOne(
{
userID,
chartID,
},
{
sort: {
"scoreData.lampIndex": -1,
},
}
)) as ScoreDocument; // guaranteed to not be null, as this always resolves
// to atleast one score (and we got ScorePB above, so we know there's
// atleast one).
const lampPB = (await db.scores.findOne(
{
userID,
chartID,
},
{
sort: {
"scoreData.lampIndex": -1,
},
}
)) as ScoreDocument; // guaranteed to not be null, as this always resolves
// to atleast one score (and we got ScorePB above, so we know there's
// atleast one).
const pbDoc = await MergeScoreLampIntoPB(userID, scorePB, lampPB, logger);
const pbDoc = await MergeScoreLampIntoPB(userID, scorePB, lampPB, logger);
if (!pbDoc) {
return;
}
if (!pbDoc) {
return;
}
// finally, return our full pbDoc, that does NOT have the ranking props.
// (We will add those later)
return pbDoc;
// finally, return our full pbDoc, that does NOT have the ranking props.
// (We will add those later)
return pbDoc;
}
/**
* Updates users' rankings on a given chart.
*/
export async function UpdateChartRanking(chartID: string) {
const scores = await db["personal-bests"].find(
{ chartID },
{
sort: {
"scoreData.percent": -1,
timeAchieved: -1,
},
}
);
const scores = await db["personal-bests"].find(
{ chartID },
{
sort: {
"scoreData.percent": -1,
timeAchieved: -1,
},
}
);
const bwrite: BulkWriteUpdateOneOperation<PBScoreDocument>[] = [];
const bwrite: BulkWriteUpdateOneOperation<PBScoreDocument>[] = [];
let rank = 0;
let rank = 0;
for (let i = 0; i < scores.length; i++) {
const score = scores[i];
rank++;
for (let i = 0; i < scores.length; i++) {
const score = scores[i];
rank++;
bwrite.push({
updateOne: {
filter: { chartID: score.chartID, userID: score.userID },
update: {
$set: {
rankingInfo: {
rank,
outOf: scores.length,
},
},
},
},
});
}
bwrite.push({
updateOne: {
filter: { chartID: score.chartID, userID: score.userID },
update: {
$set: {
rankingInfo: {
rank,
outOf: scores.length,
},
},
},
},
});
}
await db["personal-bests"].bulkWrite(bwrite, { ordered: false });
await db["personal-bests"].bulkWrite(bwrite, { ordered: false });
}
// Explicit acknowledgement that typing this properly simply takes too much time
@@ -97,67 +97,67 @@ export async function UpdateChartRanking(chartID: string) {
// They return true on success, false on failure, and mutate their arguments.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const GAME_SPECIFIC_MERGE_FNS: Record<string, any> = {
iidx: IIDXMergeFn,
sdvx: SDVXMergeFn,
iidx: IIDXMergeFn,
sdvx: SDVXMergeFn,
};
async function MergeScoreLampIntoPB(
userID: integer,
scorePB: ScoreDocument,
lampPB: ScoreDocument,
logger: KtLogger
userID: integer,
scorePB: ScoreDocument,
lampPB: ScoreDocument,
logger: KtLogger
): Promise<PBScoreDocumentNoRank | void> {
// @hack
// since time cannot be negative, this is a rough hack
// to resolve nullable timeAchieveds without hitting NaN.
let timeAchieved: number | null = Math.max(
scorePB.timeAchieved ?? -1,
lampPB.timeAchieved ?? -1
);
// @hack
// since time cannot be negative, this is a rough hack
// to resolve nullable timeAchieveds without hitting NaN.
let timeAchieved: number | null = Math.max(
scorePB.timeAchieved ?? -1,
lampPB.timeAchieved ?? -1
);
if (timeAchieved === -1) {
timeAchieved = null;
}
if (timeAchieved === -1) {
timeAchieved = null;
}
const pbDoc: PBScoreDocumentNoRank = {
composedFrom: {
scorePB: scorePB.scoreID,
lampPB: lampPB.scoreID,
},
chartID: scorePB.chartID,
comments: [scorePB.comment, lampPB.comment].filter((e) => e !== null) as string[],
userID,
songID: scorePB.songID,
highlight: scorePB.highlight || lampPB.highlight,
timeAchieved,
game: scorePB.game,
playtype: scorePB.playtype,
isPrimary: scorePB.isPrimary,
scoreData: {
score: scorePB.scoreData.score,
percent: scorePB.scoreData.percent,
esd: scorePB.scoreData.esd,
grade: scorePB.scoreData.grade,
gradeIndex: scorePB.scoreData.gradeIndex,
lamp: lampPB.scoreData.lamp,
lampIndex: lampPB.scoreData.lampIndex,
hitData: scorePB.scoreData.hitData,
hitMeta: scorePB.scoreData.hitMeta, // this will probably be overrode by game-specific fns
},
calculatedData: scorePB.calculatedData,
};
const pbDoc: PBScoreDocumentNoRank = {
composedFrom: {
scorePB: scorePB.scoreID,
lampPB: lampPB.scoreID,
},
chartID: scorePB.chartID,
comments: [scorePB.comment, lampPB.comment].filter((e) => e !== null) as string[],
userID,
songID: scorePB.songID,
highlight: scorePB.highlight || lampPB.highlight,
timeAchieved,
game: scorePB.game,
playtype: scorePB.playtype,
isPrimary: scorePB.isPrimary,
scoreData: {
score: scorePB.scoreData.score,
percent: scorePB.scoreData.percent,
esd: scorePB.scoreData.esd,
grade: scorePB.scoreData.grade,
gradeIndex: scorePB.scoreData.gradeIndex,
lamp: lampPB.scoreData.lamp,
lampIndex: lampPB.scoreData.lampIndex,
hitData: scorePB.scoreData.hitData,
hitMeta: scorePB.scoreData.hitMeta, // this will probably be overrode by game-specific fns
},
calculatedData: scorePB.calculatedData,
};
const GameSpecificMergeFn = GAME_SPECIFIC_MERGE_FNS[scorePB.game];
if (GameSpecificMergeFn) {
const success = await GameSpecificMergeFn(pbDoc, scorePB, lampPB, logger);
const GameSpecificMergeFn = GAME_SPECIFIC_MERGE_FNS[scorePB.game];
if (GameSpecificMergeFn) {
const success = await GameSpecificMergeFn(pbDoc, scorePB, lampPB, logger);
// If the mergeFn returns false, this means something has gone
// rather wrong. We just return undefined here, which in turn
// tells our calling code to skip this PB entirely.
if (success === false) {
return;
}
}
// If the mergeFn returns false, this means something has gone
// rather wrong. We just return undefined here, which in turn
// tells our calling code to skip this PB entirely.
if (success === false) {
return;
}
}
return pbDoc;
return pbDoc;
}
@@ -6,57 +6,57 @@ import { CalculateVF6 } from "../calculated-data/stats";
import { InternalFailure } from "../common/converter-failures";
export async function IIDXMergeFn(
pbDoc: PBScoreDocument<"iidx:SP" | "iidx:DP">,
scorePB: ScoreDocument<"iidx:SP" | "iidx:DP">,
lampPB: ScoreDocument<"iidx:SP" | "iidx:DP">,
logger: KtLogger
pbDoc: PBScoreDocument<"iidx:SP" | "iidx:DP">,
scorePB: ScoreDocument<"iidx:SP" | "iidx:DP">,
lampPB: ScoreDocument<"iidx:SP" | "iidx:DP">,
logger: KtLogger
): Promise<boolean> {
// lampRating needs to be updated.
pbDoc.calculatedData.ktLampRating = lampPB.calculatedData.ktLampRating;
// lampRating needs to be updated.
pbDoc.calculatedData.ktLampRating = lampPB.calculatedData.ktLampRating;
// bad+poor PB document. This is a weird, third indepdenent metric that IIDX players sometimes care about.
const bpPB = (await db.scores.findOne(
{
userID: scorePB.userID,
chartID: scorePB.chartID,
"scoreData.hitMeta.bp": { $exists: true },
},
{
sort: {
"scoreData.hitMeta.bp": 1, // bp 0 is the best BP, bp 1 is worse, so on
},
}
)) as ScoreDocument<"iidx:SP" | "iidx:DP">;
// bad+poor PB document. This is a weird, third indepdenent metric that IIDX players sometimes care about.
const bpPB = (await db.scores.findOne(
{
userID: scorePB.userID,
chartID: scorePB.chartID,
"scoreData.hitMeta.bp": { $exists: true },
},
{
sort: {
"scoreData.hitMeta.bp": 1, // bp 0 is the best BP, bp 1 is worse, so on
},
}
)) as ScoreDocument<"iidx:SP" | "iidx:DP">;
if (!bpPB) {
logger.verbose(
`Could not find BP PB for ${scorePB.userID} ${scorePB.chartID} in PB joining. User likely has no scores with BP defined.`,
{ pbDoc }
);
// this isn't actually an error! we just don't have to do anything.
return true;
}
if (!bpPB) {
logger.verbose(
`Could not find BP PB for ${scorePB.userID} ${scorePB.chartID} in PB joining. User likely has no scores with BP defined.`,
{ pbDoc }
);
// this isn't actually an error! we just don't have to do anything.
return true;
}
// by default scorePB is chosen for hitMeta fields, so, we can skip any assignments here by returning here.
if (bpPB.scoreID === scorePB.scoreID) {
logger.debug(`Skipped merging BP PB as scorePB was also BP PB.`);
return true;
} else if (bpPB.scoreID === lampPB.scoreID) {
pbDoc.scoreData.hitMeta.bp = lampPB.scoreData.hitMeta.bp;
logger.debug(`Skipped adding BP PB as composedFrom because lampPB was also BP PB.`);
return true;
}
// by default scorePB is chosen for hitMeta fields, so, we can skip any assignments here by returning here.
if (bpPB.scoreID === scorePB.scoreID) {
logger.debug(`Skipped merging BP PB as scorePB was also BP PB.`);
return true;
} else if (bpPB.scoreID === lampPB.scoreID) {
pbDoc.scoreData.hitMeta.bp = lampPB.scoreData.hitMeta.bp;
logger.debug(`Skipped adding BP PB as composedFrom because lampPB was also BP PB.`);
return true;
}
pbDoc.scoreData.hitMeta.bp = bpPB.scoreData.hitMeta.bp!;
pbDoc.scoreData.hitMeta.bp = bpPB.scoreData.hitMeta.bp!;
pbDoc.composedFrom.other = [{ name: "Best BP", scoreID: bpPB.scoreID }];
pbDoc.composedFrom.other = [{ name: "Best BP", scoreID: bpPB.scoreID }];
// Update lamp related iidx-specific info from the lampPB.
pbDoc.scoreData.hitMeta.gsm = lampPB.scoreData.hitMeta.gsm ?? null;
pbDoc.scoreData.hitMeta.gauge = lampPB.scoreData.hitMeta.gauge ?? null;
pbDoc.scoreData.hitMeta.gaugeHistory = lampPB.scoreData.hitMeta.gaugeHistory ?? null;
// Update lamp related iidx-specific info from the lampPB.
pbDoc.scoreData.hitMeta.gsm = lampPB.scoreData.hitMeta.gsm ?? null;
pbDoc.scoreData.hitMeta.gauge = lampPB.scoreData.hitMeta.gauge ?? null;
pbDoc.scoreData.hitMeta.gaugeHistory = lampPB.scoreData.hitMeta.gaugeHistory ?? null;
return true;
return true;
}
/**
@@ -66,28 +66,28 @@ export async function IIDXMergeFn(
* re-calculated for any different permutation of scorePB + lampPB.
*/
export async function SDVXMergeFn(
pbDoc: PBScoreDocument<"sdvx:Single">,
scorePB: ScoreDocument<"sdvx:Single">,
lampPB: ScoreDocument<"sdvx:Single">,
logger: KtLogger
pbDoc: PBScoreDocument<"sdvx:Single">,
scorePB: ScoreDocument<"sdvx:Single">,
lampPB: ScoreDocument<"sdvx:Single">,
logger: KtLogger
): Promise<boolean> {
// @optimisable
// This is a re-fetch, but it's difficult to pass the chart all
// the way down here due to how chartIDs (set) works. :(
const chart = await FindChartWithChartID("sdvx", pbDoc.chartID);
// @optimisable
// This is a re-fetch, but it's difficult to pass the chart all
// the way down here due to how chartIDs (set) works. :(
const chart = await FindChartWithChartID("sdvx", pbDoc.chartID);
if (!chart) {
logger.severe(`Chart ${pbDoc.chartID} disappeared underfoot?`);
throw new InternalFailure(`Chart ${pbDoc.chartID} disappeared underfoot?`);
}
if (!chart) {
logger.severe(`Chart ${pbDoc.chartID} disappeared underfoot?`);
throw new InternalFailure(`Chart ${pbDoc.chartID} disappeared underfoot?`);
}
pbDoc.calculatedData.VF6 = CalculateVF6(
pbDoc.scoreData.grade,
pbDoc.scoreData.lamp,
pbDoc.scoreData.percent,
chart.levelNum,
logger
);
pbDoc.calculatedData.VF6 = CalculateVF6(
pbDoc.scoreData.grade,
pbDoc.scoreData.lamp,
pbDoc.scoreData.percent,
chart.levelNum,
logger
);
return true;
return true;
}
@@ -11,52 +11,52 @@ import { CloseAllConnections } from "../../../../test-utils/close-connections";
const logger = CreateLogCtx(__filename);
t.test("#ProcessPBs", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should successfully insert a pb into the score-pb database", async (t) => {
await db["personal-bests"].remove({});
t.test("Should successfully insert a pb into the score-pb database", async (t) => {
await db["personal-bests"].remove({});
// scores on 511 SPA are pre-loaded into the database
await ProcessPBs(1, new Set([Testing511SPA.chartID]), logger);
// scores on 511 SPA are pre-loaded into the database
await ProcessPBs(1, new Set([Testing511SPA.chartID]), logger);
const pbs = await db["personal-bests"].find({});
const pbs = await db["personal-bests"].find({});
t.equal(pbs.length, 1, "Should match the amount of PBs inserted into the DB.");
t.equal(pbs.length, 1, "Should match the amount of PBs inserted into the DB.");
t.end();
});
t.end();
});
t.test("Should successfully insert multiple pbs into the score-pb database", async (t) => {
await db["personal-bests"].remove({});
t.test("Should successfully insert multiple pbs into the score-pb database", async (t) => {
await db["personal-bests"].remove({});
await db.scores.insert([
// @ts-expect-error lol
deepmerge(TestingIIDXSPScore, {
chartID: "test1",
scoreID: crypto.randomBytes(20).toString("hex"),
}),
// @ts-expect-error lol
deepmerge(TestingIIDXSPScore, {
chartID: "test2",
scoreID: crypto.randomBytes(20).toString("hex"),
}),
// @ts-expect-error lol
deepmerge(TestingIIDXSPScore, {
chartID: "test3",
scoreID: crypto.randomBytes(20).toString("hex"),
}),
]);
await db.scores.insert([
// @ts-expect-error lol
deepmerge(TestingIIDXSPScore, {
chartID: "test1",
scoreID: crypto.randomBytes(20).toString("hex"),
}),
// @ts-expect-error lol
deepmerge(TestingIIDXSPScore, {
chartID: "test2",
scoreID: crypto.randomBytes(20).toString("hex"),
}),
// @ts-expect-error lol
deepmerge(TestingIIDXSPScore, {
chartID: "test3",
scoreID: crypto.randomBytes(20).toString("hex"),
}),
]);
await ProcessPBs(1, new Set([Testing511SPA.chartID, "test1", "test2", "test3"]), logger);
await ProcessPBs(1, new Set([Testing511SPA.chartID, "test1", "test2", "test3"]), logger);
const pbs = await db["personal-bests"].find({});
const pbs = await db["personal-bests"].find({});
t.equal(pbs.length, 4, "Should match the amount of PBs inserted into the DB.");
t.equal(pbs.length, 4, "Should match the amount of PBs inserted into the DB.");
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -4,57 +4,57 @@ import { KtLogger } from "../../../logger/logger";
import { CreatePBDoc, UpdateChartRanking, PBScoreDocumentNoRank } from "./create-pb-doc";
export async function ProcessPBs(
userID: integer,
chartIDs: Set<string>,
logger: KtLogger
userID: integer,
chartIDs: Set<string>,
logger: KtLogger
): Promise<void> {
if (chartIDs.size === 0) {
return;
}
if (chartIDs.size === 0) {
return;
}
const promises = [];
const promises = [];
for (const chartID of chartIDs) {
promises.push(CreatePBDoc(userID, chartID, logger));
}
for (const chartID of chartIDs) {
promises.push(CreatePBDoc(userID, chartID, logger));
}
const pbDocsReturn = await Promise.all(promises);
const pbDocsReturn = await Promise.all(promises);
const pbDocs: PBScoreDocumentNoRank[] = [];
const pbDocs: PBScoreDocumentNoRank[] = [];
for (const doc of pbDocsReturn) {
if (!doc) {
continue;
}
for (const doc of pbDocsReturn) {
if (!doc) {
continue;
}
pbDocs.push(doc);
}
pbDocs.push(doc);
}
if (pbDocsReturn.length === 0) {
return;
}
if (pbDocsReturn.length === 0) {
return;
}
// so here's the kinda awkward part - for the time between this operation
// and the next one - THE SCORE PBS ARE IN THE DATABASE WITHOUT RANKINGINFO.
// this *is* bad behaviour, but I don't have a nice way to fix it.
// This should be fixed in the future to avoid crashes between these two
// calls - but that is unlikely.
await db["personal-bests"].bulkWrite(
pbDocs.map((e) => ({
updateOne: {
filter: { chartID: e.chartID, userID: e.userID },
update: { $set: e },
upsert: true,
},
})),
{
ordered: false,
}
);
// so here's the kinda awkward part - for the time between this operation
// and the next one - THE SCORE PBS ARE IN THE DATABASE WITHOUT RANKINGINFO.
// this *is* bad behaviour, but I don't have a nice way to fix it.
// This should be fixed in the future to avoid crashes between these two
// calls - but that is unlikely.
await db["personal-bests"].bulkWrite(
pbDocs.map((e) => ({
updateOne: {
filter: { chartID: e.chartID, userID: e.userID },
update: { $set: e },
upsert: true,
},
})),
{
ordered: false,
}
);
// now that everything has been updated or inserted, we can refresh
// the chart rankings.
await Promise.all(pbDocs.map((e) => UpdateChartRanking(e.chartID)));
// now that everything has been updated or inserted, we can refresh
// the chart rankings.
await Promise.all(pbDocs.map((e) => UpdateChartRanking(e.chartID)));
// and we're done!
// and we're done!
}
@@ -1,12 +1,12 @@
import {
ImportProcessingInfo,
ImportTypes,
ImportDocument,
PublicUserDocument,
Playtypes,
Game,
integer,
IDStrings,
ImportProcessingInfo,
ImportTypes,
ImportDocument,
PublicUserDocument,
Playtypes,
Game,
integer,
IDStrings,
} from "tachi-common";
import { KtLogger } from "../../logger/logger";
import { ImportAllIterableData } from "./score-importing/score-importing";
@@ -29,225 +29,225 @@ import ScoreImportFatalError from "./score-importing/score-import-error";
* Performs a Score Import.
*/
export default async function ScoreImportMain<D, C>(
user: PublicUserDocument,
userIntent: boolean,
importType: ImportTypes,
InputParser: ImportInputParser<D, C>,
providedImportObjects?: { logger: KtLogger; importID: string }
user: PublicUserDocument,
userIntent: boolean,
importType: ImportTypes,
InputParser: ImportInputParser<D, C>,
providedImportObjects?: { logger: KtLogger; importID: string }
) {
// in the event of any error, we remove the user lock.
try {
const lock = await GetOrSetUserLock(user.id);
// in the event of any error, we remove the user lock.
try {
const lock = await GetOrSetUserLock(user.id);
if (lock) {
// @danger
// Throwing away an import if the user already has one outgoing is *bad*, as in the case
// of degraded performance we might just start throwing scores away. This is obviously
// not great, but any other solution involves making a queue, which can't be done because
// InputParser is a very dynamic function that cannot be stored in redis or something.
//
// Under normal circumstances, there is no scenario where a user would have two ongoing
// imports at the same time - even if they were using single-score imports on a 5 second
// chart, as each score import takes only around ~10-15miliseconds.
throw new ScoreImportFatalError(409, "This user already has an ongoing import.");
}
if (lock) {
// @danger
// Throwing away an import if the user already has one outgoing is *bad*, as in the case
// of degraded performance we might just start throwing scores away. This is obviously
// not great, but any other solution involves making a queue, which can't be done because
// InputParser is a very dynamic function that cannot be stored in redis or something.
//
// Under normal circumstances, there is no scenario where a user would have two ongoing
// imports at the same time - even if they were using single-score imports on a 5 second
// chart, as each score import takes only around ~10-15miliseconds.
throw new ScoreImportFatalError(409, "This user already has an ongoing import.");
}
const timeStarted = Date.now();
let importID;
let logger;
const timeStarted = Date.now();
let importID;
let logger;
if (!providedImportObjects) {
// If they weren't given to us -
// we create an "import logger".
// this holds a reference to the user's name, ID, and type
// of score import for any future debugging.
({ importID, logger } = CreateImportLoggerAndID(user, importType));
logger.debug("Received import request.");
} else {
({ importID, logger } = providedImportObjects);
}
if (!providedImportObjects) {
// If they weren't given to us -
// we create an "import logger".
// this holds a reference to the user's name, ID, and type
// of score import for any future debugging.
({ importID, logger } = CreateImportLoggerAndID(user, importType));
logger.debug("Received import request.");
} else {
({ importID, logger } = providedImportObjects);
}
// --- 1. Parsing ---
// 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);
// --- 1. Parsing ---
// 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 parseTime = GetMilisecondsSince(parseTimeStart);
const parseTime = GetMilisecondsSince(parseTimeStart);
logger.debug(`Parsing took ${parseTime} miliseconds.`);
logger.debug(`Parsing took ${parseTime} miliseconds.`);
// We have to cast here due to typescript generic confusions. This is guaranteed` to be correct.
const ConverterFunction = Converters[importType] as unknown as ConverterFunction<D, C>;
// We have to cast here due to typescript generic confusions. This is guaranteed` to be correct.
const ConverterFunction = Converters[importType] as unknown as ConverterFunction<D, C>;
// --- 2. Importing ---
// ImportAllIterableData iterates over the iterable, applying the converter function to each bit of data.
const importTimeStart = process.hrtime.bigint();
const importInfo = await ImportAllIterableData(
user.id,
importType,
iterable,
ConverterFunction,
context,
logger
);
// --- 2. Importing ---
// ImportAllIterableData iterates over the iterable, applying the converter function to each bit of data.
const importTimeStart = process.hrtime.bigint();
const importInfo = await ImportAllIterableData(
user.id,
importType,
iterable,
ConverterFunction,
context,
logger
);
const importTime = GetMilisecondsSince(importTimeStart);
const importTimeRel = importTime / importInfo.length;
const importTime = GetMilisecondsSince(importTimeStart);
const importTimeRel = importTime / importInfo.length;
logger.debug(`Importing took ${importTime} miliseconds. (${importTimeRel}ms/doc)`);
logger.debug(`Importing took ${importTime} miliseconds. (${importTimeRel}ms/doc)`);
// --- 3. ParseImportInfo ---
// ImportInfo is a relatively complex structure. We need some information from it for subsequent steps
// such as the list of chartIDs involved in this import.
const importParseTimeStart = process.hrtime.bigint();
const { scorePlaytypeMap, errors, scoreIDs, chartIDs } = ParseImportInfo(importInfo);
// --- 3. ParseImportInfo ---
// ImportInfo is a relatively complex structure. We need some information from it for subsequent steps
// such as the list of chartIDs involved in this import.
const importParseTimeStart = process.hrtime.bigint();
const { scorePlaytypeMap, errors, scoreIDs, chartIDs } = ParseImportInfo(importInfo);
const importParseTime = GetMilisecondsSince(importParseTimeStart);
const importParseTimeRel = importParseTime / importInfo.length;
const importParseTime = GetMilisecondsSince(importParseTimeStart);
const importParseTimeRel = importParseTime / importInfo.length;
logger.debug(
`Import Parsing took ${importParseTime} miliseconds. (${importParseTimeRel}ms/doc)`
);
logger.debug(
`Import Parsing took ${importParseTime} miliseconds. (${importParseTimeRel}ms/doc)`
);
// --- 4. Sessions ---
// We create (or update existing) sessions here. This uses the aforementioned parsed import info
// to determine what goes where.
const sessionTimeStart = process.hrtime.bigint();
const sessionInfo = await CreateSessions(
user.id,
importType,
game,
scorePlaytypeMap,
logger
);
// --- 4. Sessions ---
// We create (or update existing) sessions here. This uses the aforementioned parsed import info
// to determine what goes where.
const sessionTimeStart = process.hrtime.bigint();
const sessionInfo = await CreateSessions(
user.id,
importType,
game,
scorePlaytypeMap,
logger
);
const sessionTime = GetMilisecondsSince(sessionTimeStart);
const sessionTimeRel = sessionTime / sessionInfo.length;
const sessionTime = GetMilisecondsSince(sessionTimeStart);
const sessionTimeRel = sessionTime / sessionInfo.length;
logger.debug(
`Session Processing took ${sessionTime} miliseconds (${sessionTimeRel}ms/doc).`
);
logger.debug(
`Session Processing took ${sessionTime} miliseconds (${sessionTimeRel}ms/doc).`
);
// --- 5. PersonalBests ---
// We want to keep an updated reference of a users best score on a given chart.
// This function also handles conjoining different scores together (such as unioning best lamp and
// best score).
const pbTimeStart = process.hrtime.bigint();
await ProcessPBs(user.id, chartIDs, logger);
// --- 5. PersonalBests ---
// We want to keep an updated reference of a users best score on a given chart.
// This function also handles conjoining different scores together (such as unioning best lamp and
// best score).
const pbTimeStart = process.hrtime.bigint();
await ProcessPBs(user.id, chartIDs, logger);
const pbTime = GetMilisecondsSince(pbTimeStart);
const pbTimeRel = pbTime / chartIDs.size;
const pbTime = GetMilisecondsSince(pbTimeStart);
const pbTimeRel = pbTime / chartIDs.size;
logger.debug(`PB Processing took ${pbTime} miliseconds (${pbTimeRel}ms/doc)`);
logger.debug(`PB Processing took ${pbTime} miliseconds (${pbTimeRel}ms/doc)`);
const playtypes = Object.keys(scorePlaytypeMap) as Playtypes[Game][];
const playtypes = Object.keys(scorePlaytypeMap) as Playtypes[Game][];
// --- 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
);
// --- 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 ugsTime = GetMilisecondsSince(ugsTimeStart);
const ugsTime = GetMilisecondsSince(ugsTimeStart);
logger.debug(`UGS Processing took ${ugsTime} miliseconds.`);
logger.debug(`UGS Processing took ${ugsTime} miliseconds.`);
// --- 7. Goals ---
// Evaluate and update the users goals. This returns information about goals that have changed.
const goalTimeStart = process.hrtime.bigint();
const goalInfo = await GetAndUpdateUsersGoals(game, user.id, chartIDs, logger);
// --- 7. Goals ---
// Evaluate and update the users goals. This returns information about goals that have changed.
const goalTimeStart = process.hrtime.bigint();
const goalInfo = await GetAndUpdateUsersGoals(game, user.id, chartIDs, logger);
const goalTime = GetMilisecondsSince(goalTimeStart);
const goalTime = GetMilisecondsSince(goalTimeStart);
logger.debug(`Goal Processing took ${goalTime} miliseconds.`);
logger.debug(`Goal Processing took ${goalTime} miliseconds.`);
// --- 8. Milestones ---
// Evaluate and update the users milestones. This returns...
const milestoneTimeStart = process.hrtime.bigint();
const milestoneInfo = await UpdateUsersMilestones(
goalInfo,
game,
playtypes,
user.id,
logger
);
// --- 8. Milestones ---
// Evaluate and update the users milestones. This returns...
const milestoneTimeStart = process.hrtime.bigint();
const milestoneInfo = await UpdateUsersMilestones(
goalInfo,
game,
playtypes,
user.id,
logger
);
const milestoneTime = GetMilisecondsSince(milestoneTimeStart);
const milestoneTime = GetMilisecondsSince(milestoneTimeStart);
logger.debug(`Milestone Processing took ${milestoneTime} miliseconds.`);
logger.debug(`Milestone Processing took ${milestoneTime} miliseconds.`);
// --- 9. Finalise Import Document ---
// Create and Save an import document to the database, and finish everything up!
const ImportDocument: ImportDocument = {
importType,
idStrings: playtypes.map((e) => `${game}:${e}`) as IDStrings[],
scoreIDs,
errors,
importID,
timeFinished: Date.now(),
timeStarted,
createdSessions: sessionInfo,
userID: user.id,
classDeltas,
goalInfo,
milestoneInfo,
userIntent,
};
// --- 9. Finalise Import Document ---
// Create and Save an import document to the database, and finish everything up!
const ImportDocument: ImportDocument = {
importType,
idStrings: playtypes.map((e) => `${game}:${e}`) as IDStrings[],
scoreIDs,
errors,
importID,
timeFinished: Date.now(),
timeStarted,
createdSessions: sessionInfo,
userID: user.id,
classDeltas,
goalInfo,
milestoneInfo,
userIntent,
};
const logMessage = `Import took: ${ImportDocument.timeFinished - timeStarted}ms, with ${
importInfo.length
} documents (Fails: ${errors.length}, Successes: ${scoreIDs.length}, Sessions: ${
sessionInfo.length
}). Aprx ${(ImportDocument.timeFinished - timeStarted) / importInfo.length}ms/doc`;
const logMessage = `Import took: ${ImportDocument.timeFinished - timeStarted}ms, with ${
importInfo.length
} documents (Fails: ${errors.length}, Successes: ${scoreIDs.length}, Sessions: ${
sessionInfo.length
}). Aprx ${(ImportDocument.timeFinished - timeStarted) / importInfo.length}ms/doc`;
// I only really want to log "big" imports. The others are here for debugging purposes.
if (scoreIDs.length > 500) {
logger.info(logMessage);
} else if (scoreIDs.length > 1) {
logger.verbose(logMessage);
} else {
logger.debug(logMessage);
}
// I only really want to log "big" imports. The others are here for debugging purposes.
if (scoreIDs.length > 500) {
logger.info(logMessage);
} else if (scoreIDs.length > 1) {
logger.verbose(logMessage);
} else {
logger.debug(logMessage);
}
await db.imports.insert(ImportDocument);
await db.imports.insert(ImportDocument);
// we don't await this because we don't
// particularly care about waiting for it.
db["import-timings"].insert({
importID,
timestamp: Date.now(),
total: ImportDocument.timeFinished - timeStarted,
rel: {
import: importTimeRel,
importParse: importParseTimeRel,
pb: pbTimeRel,
session: sessionTimeRel,
},
abs: {
parse: parseTime,
import: importTime,
importParse: importParseTime,
session: sessionTime,
pb: pbTime,
ugs: ugsTime,
goal: goalTime,
milestone: milestoneTime,
},
});
// we don't await this because we don't
// particularly care about waiting for it.
db["import-timings"].insert({
importID,
timestamp: Date.now(),
total: ImportDocument.timeFinished - timeStarted,
rel: {
import: importTimeRel,
importParse: importParseTimeRel,
pb: pbTimeRel,
session: sessionTimeRel,
},
abs: {
parse: parseTime,
import: importTime,
importParse: importParseTime,
session: sessionTime,
pb: pbTime,
ugs: ugsTime,
goal: goalTime,
milestone: milestoneTime,
},
});
await RemoveUserLock(user.id);
await RemoveUserLock(user.id);
return ImportDocument;
} catch (err) {
await RemoveUserLock(user.id);
throw err;
}
return ImportDocument;
} catch (err) {
await RemoveUserLock(user.id);
throw err;
}
}
/**
@@ -255,20 +255,20 @@ export default async function ScoreImportMain<D, C>(
* @returns A flattened array of ClassDeltas
*/
async function UpdateUsersGameStats(
game: Game,
playtypes: Playtypes[Game][],
userID: integer,
classHandler: ClassHandler | null,
logger: KtLogger
game: Game,
playtypes: Playtypes[Game][],
userID: integer,
classHandler: ClassHandler | null,
logger: KtLogger
) {
const promises = [];
const promises = [];
for (const pt of playtypes) {
promises.push(UpdateUsersGamePlaytypeStats(game, pt, userID, classHandler, logger));
}
for (const pt of playtypes) {
promises.push(UpdateUsersGamePlaytypeStats(game, pt, userID, classHandler, logger));
}
const r = await Promise.all(promises);
return r.flat(1);
const r = await Promise.all(promises);
return r.flat(1);
}
/**
@@ -279,26 +279,26 @@ async function UpdateUsersGameStats(
* on their playtype.
*/
function ParseImportInfo(importInfo: ImportProcessingInfo[]) {
const scorePlaytypeMap: ScorePlaytypeMap = Object.create(null);
const scorePlaytypeMap: ScorePlaytypeMap = Object.create(null);
const scoreIDs = [];
const errors = [];
const chartIDs: Set<string> = new Set();
const scoreIDs = [];
const errors = [];
const chartIDs: Set<string> = new Set();
for (const info of importInfo) {
if (info.success) {
scoreIDs.push(info.content.score.scoreID);
chartIDs.add(info.content.score.chartID);
for (const info of importInfo) {
if (info.success) {
scoreIDs.push(info.content.score.scoreID);
chartIDs.add(info.content.score.chartID);
if (scorePlaytypeMap[info.content.score.playtype]) {
scorePlaytypeMap[info.content.score.playtype]!.push(info.content.score);
} else {
scorePlaytypeMap[info.content.score.playtype] = [info.content.score];
}
} else {
errors.push({ type: info.type, message: info.message });
}
}
if (scorePlaytypeMap[info.content.score.playtype]) {
scorePlaytypeMap[info.content.score.playtype]!.push(info.content.score);
} else {
scorePlaytypeMap[info.content.score.playtype] = [info.content.score];
}
} else {
errors.push({ type: info.type, message: info.message });
}
}
return { scoreIDs, errors, scorePlaytypeMap, chartIDs };
return { scoreIDs, errors, scorePlaytypeMap, chartIDs };
}
@@ -2,5 +2,5 @@ import t from "tap";
import { HydrateScore } from "./hydrate-score";
t.todo("#HydrateScore", (t) => {
t.end();
t.end();
});
@@ -10,50 +10,50 @@ import { DryScore } from "../common/types";
* @param userID The userID this score is for.
*/
export async function HydrateScore(
userID: integer,
dryScore: DryScore,
chart: AnyChartDocument,
song: AnySongDocument,
scoreID: string,
logger: KtLogger
userID: integer,
dryScore: DryScore,
chart: AnyChartDocument,
song: AnySongDocument,
scoreID: string,
logger: KtLogger
): Promise<ScoreDocument> {
const esd = CalculateESDForGame(
dryScore.game,
chart.playtype,
dryScore.scoreData.percent / 100
);
const esd = CalculateESDForGame(
dryScore.game,
chart.playtype,
dryScore.scoreData.percent / 100
);
const calculatedData = await CreateCalculatedData(dryScore, chart, esd, logger);
const calculatedData = await CreateCalculatedData(dryScore, chart, esd, logger);
const { scoreData: dryScoreData, ...rest } = dryScore;
const { scoreData: dryScoreData, ...rest } = dryScore;
// Fill out the rest of the fields we want for scoreData
const scoreData = Object.assign(
{
// @todo #103 lamps may need to be separate upon game:playtype someday. Maybe. We need to check this out
lampIndex: config.lamps[dryScore.game].indexOf(dryScore.scoreData.lamp as string),
gradeIndex: config.grades[dryScore.game].indexOf(dryScore.scoreData.grade as string),
esd,
},
dryScoreData
);
// Fill out the rest of the fields we want for scoreData
const scoreData = Object.assign(
{
// @todo #103 lamps may need to be separate upon game:playtype someday. Maybe. We need to check this out
lampIndex: config.lamps[dryScore.game].indexOf(dryScore.scoreData.lamp as string),
gradeIndex: config.grades[dryScore.game].indexOf(dryScore.scoreData.grade as string),
esd,
},
dryScoreData
);
const score: ScoreDocument = {
// extract all of the non-scoreData props from a dry score and push them here
...rest,
// then push our score data.
scoreData,
// everything below this point is sane
highlight: false,
timeAdded: Date.now(),
userID,
calculatedData,
songID: song.id,
chartID: chart.chartID,
scoreID,
playtype: chart.playtype,
isPrimary: chart.isPrimary,
};
const score: ScoreDocument = {
// extract all of the non-scoreData props from a dry score and push them here
...rest,
// then push our score data.
scoreData,
// everything below this point is sane
highlight: false,
timeAdded: Date.now(),
userID,
calculatedData,
songID: song.id,
chartID: chart.chartID,
scoreID,
playtype: chart.playtype,
isPrimary: chart.isPrimary,
};
return score;
return score;
}
@@ -7,105 +7,105 @@ import { InsertQueue, QueueScoreInsert } from "./insert-score";
// these two get the same tests, because they're too closely linked
t.test("#QueueScoreInsert, #InsertQueue", async (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
// empty scoreDB after
t.afterEach(async () => {
await db.scores.remove({});
});
// empty scoreDB after
t.afterEach(async () => {
await db.scores.remove({});
});
t.test("Single Queue Test", async (t) => {
// fake score doc
const res = await QueueScoreInsert({ scoreID: "foo" } as unknown as ScoreDocument);
t.test("Single Queue Test", async (t) => {
// fake score doc
const res = await QueueScoreInsert({ scoreID: "foo" } as unknown as ScoreDocument);
t.equal(
res,
true,
"QueueScoreInsert should not insert a score when the queue is not full."
);
t.equal(
res,
true,
"QueueScoreInsert should not insert a score when the queue is not full."
);
// this is the best way to get the size of the queue
const flushSize = await InsertQueue();
// this is the best way to get the size of the queue
const flushSize = await InsertQueue();
t.equal(flushSize, 1, "QueueScoreInsert should append the score to the queue.");
t.equal(flushSize, 1, "QueueScoreInsert should append the score to the queue.");
const dbRes = await db.scores.find({
scoreID: "foo",
});
const dbRes = await db.scores.find({
scoreID: "foo",
});
t.equal(
dbRes.length,
1,
"InsertQueue should insert all 1 members of the queue into the database."
);
t.equal(
dbRes.length,
1,
"InsertQueue should insert all 1 members of the queue into the database."
);
t.end();
});
t.end();
});
let r = await InsertQueue(); // flush queue just incase former test fails.
let r = await InsertQueue(); // flush queue just incase former test fails.
t.equal(r, 0, "Queue should be empty after test.");
t.equal(r, 0, "Queue should be empty after test.");
t.test("Queue Overflow Test", async (t) => {
for (let i = 0; i < 499; i++) {
// eslint-disable-next-line no-await-in-loop
await QueueScoreInsert({ scoreID: i, chartID: "test" } as unknown as ScoreDocument);
}
t.test("Queue Overflow Test", async (t) => {
for (let i = 0; i < 499; i++) {
// eslint-disable-next-line no-await-in-loop
await QueueScoreInsert({ scoreID: i, chartID: "test" } as unknown as ScoreDocument);
}
const overflowRes = await QueueScoreInsert({
scoreID: "foo",
chartID: "test",
} as unknown as ScoreDocument);
const overflowRes = await QueueScoreInsert({
scoreID: "foo",
chartID: "test",
} as unknown as ScoreDocument);
t.equal(
overflowRes,
500,
"Appending 500 items to the queue should result in them being inserted."
);
t.equal(
overflowRes,
500,
"Appending 500 items to the queue should result in them being inserted."
);
const flushRes = await InsertQueue();
const flushRes = await InsertQueue();
t.equal(flushRes, 0, "The queue should now be empty.");
t.equal(flushRes, 0, "The queue should now be empty.");
const dbRes = await db.scores.find({
scoreID: { $exists: true },
chartID: "test",
});
const dbRes = await db.scores.find({
scoreID: { $exists: true },
chartID: "test",
});
t.equal(
dbRes.length,
500,
"InsertQueue should insert all 500 members of the queue into the database."
);
t.equal(
dbRes.length,
500,
"InsertQueue should insert all 500 members of the queue into the database."
);
t.end();
});
t.end();
});
r = await InsertQueue(); // flush queue just incase former test fails.
r = await InsertQueue(); // flush queue just incase former test fails.
t.equal(r, 0, "Queue should be empty after test.");
t.equal(r, 0, "Queue should be empty after test.");
t.test("Queue Dedupe Test", async (t) => {
await QueueScoreInsert({ scoreID: 1, chartID: "foo" } as unknown as ScoreDocument);
const r2 = await QueueScoreInsert({
scoreID: 1,
chartID: "foo",
} as unknown as ScoreDocument);
t.test("Queue Dedupe Test", async (t) => {
await QueueScoreInsert({ scoreID: 1, chartID: "foo" } as unknown as ScoreDocument);
const r2 = await QueueScoreInsert({
scoreID: 1,
chartID: "foo",
} as unknown as ScoreDocument);
t.equal(r2, null, "Should return null when a duplicate scoreID is submitted");
t.equal(r2, null, "Should return null when a duplicate scoreID is submitted");
const flushRes = await InsertQueue();
const flushRes = await InsertQueue();
t.equal(flushRes, 1, "Should flush 1 score document");
t.equal(flushRes, 1, "Should flush 1 score document");
const dbRes = await db.scores.find({
chartID: "foo",
});
const dbRes = await db.scores.find({
chartID: "foo",
});
t.equal(dbRes.length, 1, "Should only insert one document");
});
t.equal(dbRes.length, 1, "Should only insert one document");
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -14,21 +14,21 @@ const MAX_PIPELINE_LENGTH = 500;
* the score provided is already loaded.
*/
export function QueueScoreInsert(score: ScoreDocument) {
if (ScoreIDs.has(score.scoreID)) {
// skip
logger.verbose(`Triggered skip for ID ${score.scoreID}`);
return null;
}
if (ScoreIDs.has(score.scoreID)) {
// skip
logger.verbose(`Triggered skip for ID ${score.scoreID}`);
return null;
}
ScoreQueue.push(score);
ScoreIDs.add(score.scoreID);
ScoreQueue.push(score);
ScoreIDs.add(score.scoreID);
if (ScoreQueue.length >= MAX_PIPELINE_LENGTH) {
logger.verbose(`Triggered pipeline flush with len ${ScoreQueue.length}.`);
return InsertQueue();
}
if (ScoreQueue.length >= MAX_PIPELINE_LENGTH) {
logger.verbose(`Triggered pipeline flush with len ${ScoreQueue.length}.`);
return InsertQueue();
}
return true;
return true;
}
/**
@@ -36,18 +36,18 @@ export function QueueScoreInsert(score: ScoreDocument) {
* @warn Be cautious of inducing race conditions when using this function.
*/
export async function InsertQueue() {
const temp = ScoreQueue.splice(0);
if (temp.length !== 0) {
ScoreIDs = new Set();
try {
await db.scores.insert(temp);
} catch (err) {
logger.warn(
`Triggered duplicate key protection. Race condition protected against, but this is not good.`
);
return null;
}
}
const temp = ScoreQueue.splice(0);
if (temp.length !== 0) {
ScoreIDs = new Set();
try {
await db.scores.insert(temp);
} catch (err) {
logger.warn(
`Triggered duplicate key protection. Race condition protected against, but this is not good.`
);
return null;
}
}
return temp.length;
return temp.length;
}
@@ -7,90 +7,90 @@ import { CreateScoreID, GetWithScoreID } from "./score-id";
t.beforeEach(ResetDBState);
t.test("#GetWithScoreID", async (t) => {
const sc = await GetWithScoreID("TESTING_SCORE_ID");
const sc = await GetWithScoreID("TESTING_SCORE_ID");
t.not(sc, null, "Return a score for a valid score id");
t.not(sc, null, "Return a score for a valid score id");
const nl = await GetWithScoreID("INVALID SCORE ID");
const nl = await GetWithScoreID("INVALID SCORE ID");
t.equal(nl, null, "Should return null for score IDs it cannot resolve");
t.equal(nl, null, "Should return null for score IDs it cannot resolve");
t.end();
t.end();
});
t.test("#CreateScoreID", (t) => {
const scoreID = CreateScoreID(1, TestingIIDXSPDryScore, Testing511SPA.chartID);
const scoreID = CreateScoreID(1, TestingIIDXSPDryScore, Testing511SPA.chartID);
t.match(
scoreID,
/^R[0-9a-f]{40}/u,
"Should return an R followed by 40 characters of lowercase hex as scoreID."
);
t.match(
scoreID,
/^R[0-9a-f]{40}/u,
"Should return an R followed by 40 characters of lowercase hex as scoreID."
);
t.not(
scoreID,
CreateScoreID(2, TestingIIDXSPDryScore, Testing511SPA.chartID),
"Hash should be affected by userID."
);
t.not(
scoreID,
CreateScoreID(2, TestingIIDXSPDryScore, Testing511SPA.chartID),
"Hash should be affected by userID."
);
t.equal(
scoreID,
CreateScoreID(1, TestingIIDXSPDryScore, Testing511SPA.chartID),
"ScoreIDs should be consistently generatable"
);
t.equal(
scoreID,
CreateScoreID(1, TestingIIDXSPDryScore, Testing511SPA.chartID),
"ScoreIDs should be consistently generatable"
);
// duplicate score
const minimalScore = {
scoreData: {
percent: TestingIIDXSPDryScore.scoreData.percent,
score: TestingIIDXSPDryScore.scoreData.score,
grade: TestingIIDXSPDryScore.scoreData.grade,
lamp: TestingIIDXSPDryScore.scoreData.lamp,
},
};
// duplicate score
const minimalScore = {
scoreData: {
percent: TestingIIDXSPDryScore.scoreData.percent,
score: TestingIIDXSPDryScore.scoreData.score,
grade: TestingIIDXSPDryScore.scoreData.grade,
lamp: TestingIIDXSPDryScore.scoreData.lamp,
},
};
t.equal(
scoreID,
CreateScoreID(1, minimalScore as ScoreDocument, Testing511SPA.chartID),
"ScoreIDs should only be affected by score, percent, grade and lamp."
);
t.equal(
scoreID,
CreateScoreID(1, minimalScore as ScoreDocument, Testing511SPA.chartID),
"ScoreIDs should only be affected by score, percent, grade and lamp."
);
minimalScore.scoreData.percent = 0;
minimalScore.scoreData.percent = 0;
t.not(
scoreID,
CreateScoreID(1, minimalScore as ScoreDocument, Testing511SPA.chartID),
"ScoreIDs should not produce the same value if percent is different."
);
t.not(
scoreID,
CreateScoreID(1, minimalScore as ScoreDocument, Testing511SPA.chartID),
"ScoreIDs should not produce the same value if percent is different."
);
minimalScore.scoreData.percent = TestingIIDXSPDryScore.scoreData.percent;
minimalScore.scoreData.score = 0;
minimalScore.scoreData.percent = TestingIIDXSPDryScore.scoreData.percent;
minimalScore.scoreData.score = 0;
t.not(
scoreID,
CreateScoreID(1, minimalScore as ScoreDocument, Testing511SPA.chartID),
"ScoreIDs should not produce the same value if score is different."
);
t.not(
scoreID,
CreateScoreID(1, minimalScore as ScoreDocument, Testing511SPA.chartID),
"ScoreIDs should not produce the same value if score is different."
);
minimalScore.scoreData.score = TestingIIDXSPDryScore.scoreData.score;
minimalScore.scoreData.lamp = "ASSIST CLEAR";
minimalScore.scoreData.score = TestingIIDXSPDryScore.scoreData.score;
minimalScore.scoreData.lamp = "ASSIST CLEAR";
t.not(
scoreID,
CreateScoreID(1, minimalScore as ScoreDocument, Testing511SPA.chartID),
"ScoreIDs should not produce the same value if lamp is different."
);
t.not(
scoreID,
CreateScoreID(1, minimalScore as ScoreDocument, Testing511SPA.chartID),
"ScoreIDs should not produce the same value if lamp is different."
);
minimalScore.scoreData.lamp = TestingIIDXSPDryScore.scoreData.lamp;
minimalScore.scoreData.grade = "F";
minimalScore.scoreData.lamp = TestingIIDXSPDryScore.scoreData.lamp;
minimalScore.scoreData.grade = "F";
t.not(
scoreID,
CreateScoreID(1, minimalScore as ScoreDocument, Testing511SPA.chartID),
"ScoreIDs should not produce the same value if grade is different."
);
t.not(
scoreID,
CreateScoreID(1, minimalScore as ScoreDocument, Testing511SPA.chartID),
"ScoreIDs should not produce the same value if grade is different."
);
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -4,9 +4,9 @@ import { integer } from "tachi-common";
import { DryScore } from "../common/types";
function CreateScoreIDString(userID: integer, partialScore: DryScore, chartID: string) {
const { lamp, grade } = partialScore.scoreData;
const { lamp, grade } = partialScore.scoreData;
return `${userID}|${chartID}|${lamp}|${grade}|${partialScore.scoreData.score}|${partialScore.scoreData.percent}`;
return `${userID}|${chartID}|${lamp}|${grade}|${partialScore.scoreData.score}|${partialScore.scoreData.percent}`;
}
/**
@@ -15,7 +15,7 @@ function CreateScoreIDString(userID: integer, partialScore: DryScore, chartID: s
* @returns A sha256 checksum in lowercase hex.
*/
function HashScoreIDString(scoreIDString: string) {
return crypto.createHash("sha256").update(scoreIDString).digest("hex");
return crypto.createHash("sha256").update(scoreIDString).digest("hex");
}
/**
@@ -24,13 +24,13 @@ function HashScoreIDString(scoreIDString: string) {
* @returns @see HashScoreIDString - prefixed with R.
*/
export function CreateScoreID(userID: integer, dryScore: DryScore, chartID: string) {
const scoreIDString = CreateScoreIDString(userID, dryScore, chartID);
const scoreIDString = CreateScoreIDString(userID, dryScore, chartID);
return `R${HashScoreIDString(scoreIDString)}`;
return `R${HashScoreIDString(scoreIDString)}`;
}
export function GetWithScoreID(scoreID: string) {
return db.scores.findOne({
scoreID,
});
return db.scores.findOne({
scoreID,
});
}
@@ -2,10 +2,10 @@ import t from "tap";
import ScoreImportFatalError from "./score-import-error";
t.test("#new ScoreImportFatalError()", (t) => {
const err = new ScoreImportFatalError(500, "error message");
const err = new ScoreImportFatalError(500, "error message");
t.equal(err.statusCode, 500, "Should store the status code passed to it.");
t.equal(err.message, "error message", "Should store description as the error message.");
t.equal(err.statusCode, 500, "Should store the status code passed to it.");
t.equal(err.message, "error message", "Should store description as the error message.");
t.end();
t.end();
});
@@ -6,11 +6,11 @@
* process.
*/
export default class ScoreImportFatalError extends Error {
statusCode: number;
statusCode: number;
constructor(statusCode: number, description: string) {
super(description);
constructor(statusCode: number, description: string) {
super(description);
this.statusCode = statusCode;
}
this.statusCode = statusCode;
}
}
@@ -2,9 +2,9 @@ import t from "tap";
import { ImportAllIterableData, ImportIterableDatapoint } from "./score-importing";
t.todo("#ImportAllIterableData", (t) => {
t.end();
t.end();
});
t.todo("#ImportIterableDatapoint", (t) => {
t.end();
t.end();
});
@@ -1,29 +1,29 @@
import {
AnyChartDocument,
ImportProcessingInfo,
integer,
ScoreDocument,
AnySongDocument,
ImportTypes,
IDStrings,
AnyChartDocument,
ImportProcessingInfo,
integer,
ScoreDocument,
AnySongDocument,
ImportTypes,
IDStrings,
} from "tachi-common";
import { HydrateScore } from "./hydrate-score";
import { InsertQueue, QueueScoreInsert, ScoreIDs } from "./insert-score";
import {
ConverterFailure,
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
SkipScoreFailure,
ConverterFailure,
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
SkipScoreFailure,
} from "../common/converter-failures";
import { CreateScoreID } from "./score-id";
import db from "../../../../external/mongo/db";
import { AppendLogCtx, KtLogger } from "../../../logger/logger";
import {
ConverterFnReturnOrFailure,
ConverterFunction,
ConverterFnSuccessReturn,
ConverterFnReturnOrFailure,
ConverterFunction,
ConverterFnSuccessReturn,
} from "../../import-types/common/types";
import { DryScore } from "../common/types";
import { OrphanScore } from "../orphans/orphans";
@@ -37,49 +37,49 @@ import { OrphanScore } from "../orphans/orphans";
* @returns An array of ImportProcessInfo objects.
*/
export async function ImportAllIterableData<D, C>(
userID: integer,
importType: ImportTypes,
iterableData: Iterable<D> | AsyncIterable<D>,
ConverterFunction: ConverterFunction<D, C>,
context: C,
logger: KtLogger
userID: integer,
importType: ImportTypes,
iterableData: Iterable<D> | AsyncIterable<D>,
ConverterFunction: ConverterFunction<D, C>,
context: C,
logger: KtLogger
): Promise<ImportProcessingInfo[]> {
logger.verbose(`Starting Data Processing...`);
logger.verbose(`Starting Data Processing...`);
const promises = [];
const promises = [];
// for await is used here as iterableData may be an async iterable
// An example would be making an api request after exhausting
// the first set of data.
for await (const data of iterableData) {
promises.push(
ImportIterableDatapoint(userID, importType, data, ConverterFunction, context, logger)
);
}
// for await is used here as iterableData may be an async iterable
// An example would be making an api request after exhausting
// the first set of data.
for await (const data of iterableData) {
promises.push(
ImportIterableDatapoint(userID, importType, data, ConverterFunction, context, logger)
);
}
// We need to filter out nulls, which we don't care for (these are neither successes or failures)
const processedResults = await Promise.all(promises);
// We need to filter out nulls, which we don't care for (these are neither successes or failures)
const processedResults = await Promise.all(promises);
logger.verbose(`Finished Importing Data (${promises.length} datapoints).`);
logger.debug(`Removing null returns...`);
logger.verbose(`Finished Importing Data (${promises.length} datapoints).`);
logger.debug(`Removing null returns...`);
const datapoints = processedResults.filter(
(e) => e !== null
) as ImportProcessingInfo<IDStrings>[];
const datapoints = processedResults.filter(
(e) => e !== null
) as ImportProcessingInfo<IDStrings>[];
logger.debug(`Removed null from results.`);
logger.debug(`Removed null from results.`);
logger.verbose(`Recieved ${datapoints.length} returns, from ${promises.length} data.`);
logger.verbose(`Recieved ${datapoints.length} returns, from ${promises.length} data.`);
// Flush the score queue out after finishing most of the import. This ensures no scores get left in the
// queue.
const emptied = await InsertQueue();
// Flush the score queue out after finishing most of the import. This ensures no scores get left in the
// queue.
const emptied = await InsertQueue();
if (emptied) {
logger.verbose(`Emptied ${emptied} documents from score queue.`);
}
if (emptied) {
logger.verbose(`Emptied ${emptied} documents from score queue.`);
}
return datapoints;
return datapoints;
}
/**
@@ -91,140 +91,140 @@ export async function ImportAllIterableData<D, C>(
* @returns An array of ImportProcessingInfo objects, or a single ImportProcessingInfo object
*/
export async function ImportIterableDatapoint<D, C>(
userID: integer,
importType: ImportTypes,
data: D,
ConverterFunction: ConverterFunction<D, C>,
context: C,
logger: KtLogger
userID: integer,
importType: ImportTypes,
data: D,
ConverterFunction: ConverterFunction<D, C>,
context: C,
logger: KtLogger
): Promise<ImportProcessingInfo | null> {
// Converter Function Return
let cfnReturn: ConverterFnReturnOrFailure;
// Converter Function Return
let cfnReturn: ConverterFnReturnOrFailure;
try {
cfnReturn = await ConverterFunction(data, context, importType, logger);
} catch (err) {
cfnReturn = err;
}
try {
cfnReturn = await ConverterFunction(data, context, importType, logger);
} catch (err) {
cfnReturn = err;
}
// if this conversion failed, return it in the proper format
if (cfnReturn instanceof ConverterFailure) {
if (cfnReturn instanceof KTDataNotFoundFailure) {
logger.warn(`KTDataNotFoundFailure: ${cfnReturn.message ?? "No message?"}`, {
cfnReturn,
hideFromConsole: ["cfnReturn"],
});
// if this conversion failed, return it in the proper format
if (cfnReturn instanceof ConverterFailure) {
if (cfnReturn instanceof KTDataNotFoundFailure) {
logger.warn(`KTDataNotFoundFailure: ${cfnReturn.message ?? "No message?"}`, {
cfnReturn,
hideFromConsole: ["cfnReturn"],
});
const insertOrphan = await OrphanScore(
cfnReturn.importType,
userID,
cfnReturn.data,
cfnReturn.converterContext,
cfnReturn.message,
logger
);
const insertOrphan = await OrphanScore(
cfnReturn.importType,
userID,
cfnReturn.data,
cfnReturn.converterContext,
cfnReturn.message,
logger
);
if (insertOrphan.success) {
return {
success: false,
type: "KTDataNotFound",
message: cfnReturn.message,
content: {
context: cfnReturn.converterContext,
data: cfnReturn.data,
orphanID: insertOrphan.orphanID,
},
};
}
if (insertOrphan.success) {
return {
success: false,
type: "KTDataNotFound",
message: cfnReturn.message,
content: {
context: cfnReturn.converterContext,
data: cfnReturn.data,
orphanID: insertOrphan.orphanID,
},
};
}
return {
success: false,
type: "OrphanExists",
message: cfnReturn.message,
content: {
orphanID: insertOrphan.orphanID,
},
};
} else if (cfnReturn instanceof InvalidScoreFailure) {
logger.info(`InvalidScoreFailure: ${cfnReturn.message ?? "No message?"}`, {
cfnReturn,
hideFromConsole: ["cfnReturn"],
});
return {
success: false,
type: "InvalidDatapoint",
message: cfnReturn.message,
content: {},
};
} else if (cfnReturn instanceof InternalFailure) {
logger.error(`Internal error occured.`, { cfnReturn });
return {
success: false,
type: "InternalError",
// could return cfnReturn.message here, but we might want to hide the details of the crash.
message: "An internal error has occured.",
content: {},
};
} else if (cfnReturn instanceof SkipScoreFailure) {
return null;
} else {
logger.warn(`Unknown error returned as ConverterFailure, Ignoring.`, {
err: cfnReturn,
});
return {
success: false,
type: "InternalError",
message: "An internal service error has occured.",
content: {},
};
}
}
return {
success: false,
type: "OrphanExists",
message: cfnReturn.message,
content: {
orphanID: insertOrphan.orphanID,
},
};
} else if (cfnReturn instanceof InvalidScoreFailure) {
logger.info(`InvalidScoreFailure: ${cfnReturn.message ?? "No message?"}`, {
cfnReturn,
hideFromConsole: ["cfnReturn"],
});
return {
success: false,
type: "InvalidDatapoint",
message: cfnReturn.message,
content: {},
};
} else if (cfnReturn instanceof InternalFailure) {
logger.error(`Internal error occured.`, { cfnReturn });
return {
success: false,
type: "InternalError",
// could return cfnReturn.message here, but we might want to hide the details of the crash.
message: "An internal error has occured.",
content: {},
};
} else if (cfnReturn instanceof SkipScoreFailure) {
return null;
} else {
logger.warn(`Unknown error returned as ConverterFailure, Ignoring.`, {
err: cfnReturn,
});
return {
success: false,
type: "InternalError",
message: "An internal service error has occured.",
content: {},
};
}
}
if (cfnReturn instanceof Error) {
logger.error(`Unknown error thrown from converter, Ignoring.`, {
err: cfnReturn,
});
return {
success: false,
type: "InternalError",
message: "An internal service error has occured.",
content: {},
};
}
if (cfnReturn instanceof Error) {
logger.error(`Unknown error thrown from converter, Ignoring.`, {
err: cfnReturn,
});
return {
success: false,
type: "InternalError",
message: "An internal service error has occured.",
content: {},
};
}
return ProcessSuccessfulConverterReturn(userID, cfnReturn as ConverterFnSuccessReturn, logger);
return ProcessSuccessfulConverterReturn(userID, cfnReturn as ConverterFnSuccessReturn, logger);
}
export async function ProcessSuccessfulConverterReturn(
userID: integer,
cfnReturn: ConverterFnSuccessReturn,
logger: KtLogger
userID: integer,
cfnReturn: ConverterFnSuccessReturn,
logger: KtLogger
): Promise<ImportProcessingInfo | null> {
const result = await HydrateAndInsertScore(
userID,
cfnReturn.dryScore,
cfnReturn.chart,
cfnReturn.song,
logger
);
const result = await HydrateAndInsertScore(
userID,
cfnReturn.dryScore,
cfnReturn.chart,
cfnReturn.song,
logger
);
// This used to be a ScoreExists error. However, we never actually care about
// handling ScoreExists errors (they're nobodies issue)
// so instead, the function will just return null, and we pass that on here.
if (result === null) {
return null;
}
// This used to be a ScoreExists error. However, we never actually care about
// handling ScoreExists errors (they're nobodies issue)
// so instead, the function will just return null, and we pass that on here.
if (result === null) {
return null;
}
logger.debug(`Successfully imported score: ${result.scoreID}`);
logger.debug(`Successfully imported score: ${result.scoreID}`);
return {
success: true,
type: "ScoreImported",
message: null,
content: {
score: result,
},
};
return {
success: true,
type: "ScoreImported",
message: null,
content: {
score: result,
},
};
}
/**
@@ -235,52 +235,52 @@ export async function ProcessSuccessfulConverterReturn(
* @param song - The song this score is on.
*/
async function HydrateAndInsertScore(
userID: integer,
dryScore: DryScore,
chart: AnyChartDocument,
song: AnySongDocument,
importLogger: KtLogger
userID: integer,
dryScore: DryScore,
chart: AnyChartDocument,
song: AnySongDocument,
importLogger: KtLogger
): Promise<ScoreDocument | null> {
const scoreID = CreateScoreID(userID, dryScore, chart.chartID);
const scoreID = CreateScoreID(userID, dryScore, chart.chartID);
// sub-context the logger so the below logs are more accurate
const logger = AppendLogCtx(scoreID, importLogger);
// sub-context the logger so the below logs are more accurate
const logger = AppendLogCtx(scoreID, importLogger);
const existingScore = await db.scores.findOne(
{
scoreID,
},
{
// micro-optimisation - mongoDB is significantly faster when returning less fields
// since we only care about whether we have a score or not here, we can minimise returned
// fields.
//
// feel free to test this yourself! - zkldi (09/04/2021)
projection: {
_id: 1,
},
}
);
const existingScore = await db.scores.findOne(
{
scoreID,
},
{
// micro-optimisation - mongoDB is significantly faster when returning less fields
// since we only care about whether we have a score or not here, we can minimise returned
// fields.
//
// feel free to test this yourself! - zkldi (09/04/2021)
projection: {
_id: 1,
},
}
);
if (existingScore) {
logger.verbose(`Skipped score.`);
return null;
}
if (existingScore) {
logger.verbose(`Skipped score.`);
return null;
}
if (ScoreIDs.has(scoreID)) {
logger.verbose(`Skipped score.`);
return null;
}
if (ScoreIDs.has(scoreID)) {
logger.verbose(`Skipped score.`);
return null;
}
const score = await HydrateScore(userID, dryScore, chart, song, scoreID, logger);
const score = await HydrateScore(userID, dryScore, chart, song, scoreID, logger);
const res = await QueueScoreInsert(score);
const res = await QueueScoreInsert(score);
// emergency state - this is a last resort for avoiding doubled imports
if (res === null) {
logger.verbose(`Skipped score - Race Condition protection triggered.`);
return null;
}
// emergency state - this is a last resort for avoiding doubled imports
if (res === null) {
logger.verbose(`Skipped score - Race Condition protection triggered.`);
return null;
}
return score;
return score;
}
@@ -2,68 +2,68 @@ import t from "tap";
import { CreateSessionCalcData } from "./calculated-data";
function ratingwrap(ratings: [number, number][]) {
return ratings.map((e) => ({
calculatedData: {
ktRating: e[0],
ktLampRating: e[1],
},
}));
return ratings.map((e) => ({
calculatedData: {
ktRating: e[0],
ktLampRating: e[1],
},
}));
}
function avgbest10(arr: number[]) {
return (
arr
.sort((a, b) => b - a)
.slice(0, 10)
.reduce((a, r) => a + r, 0) / 10
);
return (
arr
.sort((a, b) => b - a)
.slice(0, 10)
.reduce((a, r) => a + r, 0) / 10
);
}
t.test("#CreateSessionCalcData", (t) => {
t.test("Should return null if less than 10 scores", (t) => {
const res = CreateSessionCalcData("iidx", "SP", ratingwrap([[1, 2]]));
t.test("Should return null if less than 10 scores", (t) => {
const res = CreateSessionCalcData("iidx", "SP", ratingwrap([[1, 2]]));
t.strictSame(res, { BPI: null, ktRating: null, ktLampRating: null });
t.strictSame(res, { BPI: null, ktRating: null, ktLampRating: null });
t.end();
});
t.end();
});
t.test("Should calculate session performance", (t) => {
const { BPI, ktRating, ktLampRating } = CreateSessionCalcData(
"iidx",
"SP",
ratingwrap([
[1, 1],
[2, 2],
[3, 4],
[4, 4],
[6, 1],
[1, 6],
[1, 1],
[2, 2],
[3, 4],
[4, 4],
[6, 1],
[1, 6],
])
);
t.test("Should calculate session performance", (t) => {
const { BPI, ktRating, ktLampRating } = CreateSessionCalcData(
"iidx",
"SP",
ratingwrap([
[1, 1],
[2, 2],
[3, 4],
[4, 4],
[6, 1],
[1, 6],
[1, 1],
[2, 2],
[3, 4],
[4, 4],
[6, 1],
[1, 6],
])
);
t.equal(
ktLampRating,
avgbest10([1, 2, 4, 4, 1, 6, 1, 2, 4, 4, 1, 6]),
"Should correctly calculate lamp performance"
);
t.equal(
ktLampRating,
avgbest10([1, 2, 4, 4, 1, 6, 1, 2, 4, 4, 1, 6]),
"Should correctly calculate lamp performance"
);
t.equal(
ktRating,
avgbest10([1, 2, 3, 4, 6, 1, 1, 2, 3, 4, 6, 1]),
"Should correctly calculate score performance"
);
t.equal(
ktRating,
avgbest10([1, 2, 3, 4, 6, 1, 1, 2, 3, 4, 6, 1]),
"Should correctly calculate score performance"
);
t.equal(BPI, null);
t.equal(BPI, null);
t.end();
});
t.end();
});
t.end();
t.end();
});
@@ -5,131 +5,131 @@ type ScoreCalculatedDataOnly = Pick<ScoreDocument, "calculatedData">;
const RELEVANT_SCORES = 10;
function AverageBest10(vals: (number | null | undefined)[]) {
const numbers = vals.filter((e) => typeof e === "number") as number[];
const numbers = vals.filter((e) => typeof e === "number") as number[];
if (numbers.length < RELEVANT_SCORES) {
return null;
}
if (numbers.length < RELEVANT_SCORES) {
return null;
}
return (
numbers
.sort((a, b) => b - a)
.slice(0, RELEVANT_SCORES)
.reduce((a, r) => a + r, 0) / RELEVANT_SCORES
);
return (
numbers
.sort((a, b) => b - a)
.slice(0, RELEVANT_SCORES)
.reduce((a, r) => a + r, 0) / RELEVANT_SCORES
);
}
function SumAll(arr: ScoreCalculatedDataOnly[], prop: keyof ScoreDocument["calculatedData"]) {
return arr.reduce((a, r) => {
if (typeof r.calculatedData[prop] === "number") {
return a + (r.calculatedData[prop] as number);
}
return arr.reduce((a, r) => {
if (typeof r.calculatedData[prop] === "number") {
return a + (r.calculatedData[prop] as number);
}
return a;
}, 0);
return a;
}, 0);
}
function AvgBest10Map(arr: ScoreCalculatedDataOnly[], prop: keyof ScoreDocument["calculatedData"]) {
return AverageBest10(arr.map((e) => e.calculatedData[prop]));
return AverageBest10(arr.map((e) => e.calculatedData[prop]));
}
type CalculatedDataFunctions = {
[G in Game]: {
[P in Playtypes[G]]: (
scoreCalcData: ScoreCalculatedDataOnly[]
) => SessionDocument["calculatedData"];
};
[G in Game]: {
[P in Playtypes[G]]: (
scoreCalcData: ScoreCalculatedDataOnly[]
) => SessionDocument["calculatedData"];
};
};
const CalculatedDataFunctions: CalculatedDataFunctions = {
iidx: {
SP: (scd) => ({
BPI: AvgBest10Map(scd, "BPI"),
ktRating: AvgBest10Map(scd, "ktRating"),
ktLampRating: AvgBest10Map(scd, "ktLampRating"),
}),
DP: (scd) => ({
BPI: AvgBest10Map(scd, "BPI"),
ktRating: AvgBest10Map(scd, "ktRating"),
ktLampRating: AvgBest10Map(scd, "ktLampRating"),
}),
},
sdvx: {
Single: (scd) => {
const VF6 = AvgBest10Map(scd, "VF6");
iidx: {
SP: (scd) => ({
BPI: AvgBest10Map(scd, "BPI"),
ktRating: AvgBest10Map(scd, "ktRating"),
ktLampRating: AvgBest10Map(scd, "ktLampRating"),
}),
DP: (scd) => ({
BPI: AvgBest10Map(scd, "BPI"),
ktRating: AvgBest10Map(scd, "ktRating"),
ktLampRating: AvgBest10Map(scd, "ktLampRating"),
}),
},
sdvx: {
Single: (scd) => {
const VF6 = AvgBest10Map(scd, "VF6");
return {
VF6,
profileVF6: VF6 === null ? null : VF6 * 50,
};
},
},
popn: {
"9B": () => ({}),
},
museca: {
Single: (scd) => ({
ktRating: AvgBest10Map(scd, "ktRating"),
}),
},
chunithm: {
Single: (scd) => ({
rating: AvgBest10Map(scd, "rating"),
}),
},
maimai: {
Single: (scd) => ({
ktRating: AvgBest10Map(scd, "ktRating"),
}),
},
gitadora: {
Gita: (scd) => ({
skill: AvgBest10Map(scd, "skill"),
}),
Dora: (scd) => ({
skill: AvgBest10Map(scd, "skill"),
}),
},
bms: {
"7K": (scd) => ({
ktLampRating: AvgBest10Map(scd, "ktLampRating"),
}),
"14K": (scd) => ({
ktLampRating: AvgBest10Map(scd, "ktLampRating"),
}),
},
ddr: {
SP: (scd) => ({
MFCP: SumAll(scd, "MFCP"),
ktRating: AvgBest10Map(scd, "ktRating"),
}),
DP: (scd) => ({
MFCP: SumAll(scd, "MFCP"),
ktRating: AvgBest10Map(scd, "ktRating"),
}),
},
jubeat: {
Single: (scd) => ({
jubility: AvgBest10Map(scd, "jubility"),
}),
},
usc: {
Single: (scd) => {
const VF6 = AvgBest10Map(scd, "VF6");
return {
VF6,
profileVF6: VF6 === null ? null : VF6 * 50,
};
},
},
popn: {
"9B": () => ({}),
},
museca: {
Single: (scd) => ({
ktRating: AvgBest10Map(scd, "ktRating"),
}),
},
chunithm: {
Single: (scd) => ({
rating: AvgBest10Map(scd, "rating"),
}),
},
maimai: {
Single: (scd) => ({
ktRating: AvgBest10Map(scd, "ktRating"),
}),
},
gitadora: {
Gita: (scd) => ({
skill: AvgBest10Map(scd, "skill"),
}),
Dora: (scd) => ({
skill: AvgBest10Map(scd, "skill"),
}),
},
bms: {
"7K": (scd) => ({
ktLampRating: AvgBest10Map(scd, "ktLampRating"),
}),
"14K": (scd) => ({
ktLampRating: AvgBest10Map(scd, "ktLampRating"),
}),
},
ddr: {
SP: (scd) => ({
MFCP: SumAll(scd, "MFCP"),
ktRating: AvgBest10Map(scd, "ktRating"),
}),
DP: (scd) => ({
MFCP: SumAll(scd, "MFCP"),
ktRating: AvgBest10Map(scd, "ktRating"),
}),
},
jubeat: {
Single: (scd) => ({
jubility: AvgBest10Map(scd, "jubility"),
}),
},
usc: {
Single: (scd) => {
const VF6 = AvgBest10Map(scd, "VF6");
return {
VF6,
profileVF6: VF6 === null ? null : VF6 * 50,
};
},
},
return {
VF6,
profileVF6: VF6 === null ? null : VF6 * 50,
};
},
},
};
export function CreateSessionCalcData(
game: Game,
playtype: Playtypes[Game],
scoreCalcData: ScoreCalculatedDataOnly[]
game: Game,
playtype: Playtypes[Game],
scoreCalcData: ScoreCalculatedDataOnly[]
): SessionDocument["calculatedData"] {
// @ts-expect-error standard game->pt stuff.
return CalculatedDataFunctions[game][playtype](scoreCalcData);
// @ts-expect-error standard game->pt stuff.
return CalculatedDataFunctions[game][playtype](scoreCalcData);
}
@@ -2,9 +2,9 @@ import t from "tap";
import { GenerateRandomSessionName } from "./name-generation";
t.test("#GenerateRandomSessionName", (t) => {
const res = GenerateRandomSessionName();
const res = GenerateRandomSessionName();
t.type(res, "string", "Should return a string.");
t.type(res, "string", "Should return a string.");
t.end();
t.end();
});
@@ -1,9 +1,9 @@
import { GetRandomAdjective, GetRandomNoun } from "../../../../datasets/words";
export function GenerateRandomSessionName() {
const adj1 = GetRandomAdjective();
const adj2 = GetRandomAdjective();
const noun = GetRandomNoun();
const adj1 = GetRandomAdjective();
const adj2 = GetRandomAdjective();
const noun = GetRandomNoun();
return `${adj1} ${adj2} ${noun}`;
return `${adj1} ${adj2} ${noun}`;
}
@@ -9,440 +9,440 @@ import { PublicUserDocument, ScoreDocument, SessionDocument } from "tachi-common
import deepmerge from "deepmerge";
const logger = CreateScoreLogger(
{ username: "test_zkldi", id: 1 } as PublicUserDocument,
"foo",
"ir/direct-manual"
{ username: "test_zkldi", id: 1 } as PublicUserDocument,
"foo",
"ir/direct-manual"
);
t.test("#CreateSessions", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(() => db.sessions.remove({}));
t.beforeEach(ResetDBState);
t.beforeEach(() => db.sessions.remove({}));
t.test("Should compose sessions from one timestamped score provided.", async (t) => {
const res = await CreateSessions(
1,
"ir/direct-manual",
"iidx",
{ SP: [TestingIIDXSPScore] },
logger
);
t.test("Should compose sessions from one timestamped score provided.", async (t) => {
const res = await CreateSessions(
1,
"ir/direct-manual",
"iidx",
{ SP: [TestingIIDXSPScore] },
logger
);
t.match(res, [
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
]);
t.match(res, [
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
]);
t.equal(res.length, 1);
t.equal(res.length, 1);
const session = await db.sessions.findOne({ userID: 1, game: "iidx", playtype: "SP" });
const session = await db.sessions.findOne({ userID: 1, game: "iidx", playtype: "SP" });
const sessionCount = await db.sessions.count({ userID: 1, game: "iidx", playtype: "SP" });
const sessionCount = await db.sessions.count({ userID: 1, game: "iidx", playtype: "SP" });
t.equal(sessionCount, 1);
t.equal(sessionCount, 1);
t.hasStrict(session, {
userID: 1,
importType: "ir/direct-manual",
// name: "adjective1 adjective2 noun1",
sessionID: res[0].sessionID,
desc: null,
game: "iidx",
playtype: "SP",
highlight: false,
scoreInfo: [
{
scoreID: "TESTING_SCORE_ID",
isNewScore: true,
},
],
// timeInserted: 1622289329729,
timeStarted: 1619454485988,
timeEnded: 1619454485988,
calculatedData: {},
});
t.hasStrict(session, {
userID: 1,
importType: "ir/direct-manual",
// name: "adjective1 adjective2 noun1",
sessionID: res[0].sessionID,
desc: null,
game: "iidx",
playtype: "SP",
highlight: false,
scoreInfo: [
{
scoreID: "TESTING_SCORE_ID",
isNewScore: true,
},
],
// timeInserted: 1622289329729,
timeStarted: 1619454485988,
timeEnded: 1619454485988,
calculatedData: {},
});
t.end();
});
t.end();
});
t.test("Should not compose sessions from untimestamped scores.", async (t) => {
const res = await CreateSessions(
1,
"ir/direct-manual",
"iidx",
{ SP: [deepmerge(TestingIIDXSPScore, { timeAchieved: null })] },
logger
);
t.test("Should not compose sessions from untimestamped scores.", async (t) => {
const res = await CreateSessions(
1,
"ir/direct-manual",
"iidx",
{ SP: [deepmerge(TestingIIDXSPScore, { timeAchieved: null })] },
logger
);
t.strictSame(res, []);
t.strictSame(res, []);
const sessionCount = await db.sessions.count({ userID: 1, game: "iidx", playtype: "SP" });
const sessionCount = await db.sessions.count({ userID: 1, game: "iidx", playtype: "SP" });
t.equal(sessionCount, 0);
t.equal(sessionCount, 0);
t.end();
});
t.end();
});
t.test("Should compose sessions for multiple playtypes.", async (t) => {
const res = await CreateSessions(
1,
"ir/direct-manual",
"iidx",
{ DP: [deepmerge(TestingIIDXSPScore, { playtype: "DP" })], SP: [TestingIIDXSPScore] },
logger
);
t.test("Should compose sessions for multiple playtypes.", async (t) => {
const res = await CreateSessions(
1,
"ir/direct-manual",
"iidx",
{ DP: [deepmerge(TestingIIDXSPScore, { playtype: "DP" })], SP: [TestingIIDXSPScore] },
logger
);
t.equal(res.length, 2);
t.equal(res.length, 2);
const sessionCount = await db.sessions.count({ userID: 1, game: "iidx", playtype: "SP" });
const sessionCount = await db.sessions.count({ userID: 1, game: "iidx", playtype: "SP" });
t.equal(sessionCount, 1);
t.equal(sessionCount, 1);
const sessionCountDP = await db.sessions.count({ userID: 1, game: "iidx", playtype: "DP" });
const sessionCountDP = await db.sessions.count({ userID: 1, game: "iidx", playtype: "DP" });
t.equal(sessionCountDP, 1);
t.equal(sessionCountDP, 1);
t.end();
});
t.end();
t.end();
});
t.end();
});
// just an arbitrary start point for our sessions as an offset
const start = 1619454485988;
t.test("#LoadScoresIntoSessions", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(() => db.sessions.remove({}));
t.beforeEach(ResetDBState);
t.beforeEach(() => db.sessions.remove({}));
t.test("Should create sessions from scores.", async (t) => {
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[null, start, start + 1000, start + 2000, start + 3000].map((e, i) =>
deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` })
) as ScoreDocument[],
"iidx",
"SP",
logger
);
t.test("Should create sessions from scores.", async (t) => {
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[null, start, start + 1000, start + 2000, start + 3000].map((e, i) =>
deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` })
) as ScoreDocument[],
"iidx",
"SP",
logger
);
t.match(res, [
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
]);
t.match(res, [
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
]);
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
t.equal(sessions.length, 1);
t.equal(sessions.length, 1);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "SCORE_ID_1",
isNewScore: true,
},
{
scoreID: "SCORE_ID_2",
isNewScore: true,
},
{
scoreID: "SCORE_ID_3",
isNewScore: true,
},
{
scoreID: "SCORE_ID_4",
isNewScore: true,
},
]);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "SCORE_ID_1",
isNewScore: true,
},
{
scoreID: "SCORE_ID_2",
isNewScore: true,
},
{
scoreID: "SCORE_ID_3",
isNewScore: true,
},
{
scoreID: "SCORE_ID_4",
isNewScore: true,
},
]);
t.end();
});
t.end();
});
t.test("Should sort scores before creating sessions.", async (t) => {
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[null, start, start - 4000, start + 2000, start - 3000, null, start + 1000].map(
(e, i) =>
deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` })
) as ScoreDocument[],
"iidx",
"SP",
logger
);
t.test("Should sort scores before creating sessions.", async (t) => {
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[null, start, start - 4000, start + 2000, start - 3000, null, start + 1000].map(
(e, i) =>
deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` })
) as ScoreDocument[],
"iidx",
"SP",
logger
);
t.match(res, [
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
]);
t.match(res, [
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
]);
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
t.equal(sessions.length, 1);
t.equal(sessions.length, 1);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "SCORE_ID_2",
isNewScore: true,
},
{
scoreID: "SCORE_ID_4",
isNewScore: true,
},
{
scoreID: "SCORE_ID_1",
isNewScore: true,
},
{
scoreID: "SCORE_ID_6",
isNewScore: true,
},
{
scoreID: "SCORE_ID_3",
isNewScore: true,
},
]);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "SCORE_ID_2",
isNewScore: true,
},
{
scoreID: "SCORE_ID_4",
isNewScore: true,
},
{
scoreID: "SCORE_ID_1",
isNewScore: true,
},
{
scoreID: "SCORE_ID_6",
isNewScore: true,
},
{
scoreID: "SCORE_ID_3",
isNewScore: true,
},
]);
t.end();
});
t.end();
});
const TWO_HOURS = 1000 * 60 * 60 * 2;
const TWO_HOURS = 1000 * 60 * 60 * 2;
t.test("Should create multiple sessions if scores are far apart.", async (t) => {
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[
null,
start,
start + 1000,
start + 2000,
start + TWO_HOURS + 4000,
start + TWO_HOURS + 5000,
start + TWO_HOURS + 6000,
].map((e, i) =>
deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` })
) as ScoreDocument[],
"iidx",
"SP",
logger
);
t.test("Should create multiple sessions if scores are far apart.", async (t) => {
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[
null,
start,
start + 1000,
start + 2000,
start + TWO_HOURS + 4000,
start + TWO_HOURS + 5000,
start + TWO_HOURS + 6000,
].map((e, i) =>
deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` })
) as ScoreDocument[],
"iidx",
"SP",
logger
);
t.match(res, [
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
]);
t.match(res, [
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
]);
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
t.equal(sessions.length, 2);
t.equal(sessions.length, 2);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "SCORE_ID_1",
isNewScore: true,
},
{
scoreID: "SCORE_ID_2",
isNewScore: true,
},
{
scoreID: "SCORE_ID_3",
isNewScore: true,
},
]);
t.strictSame(sessions[1].scoreInfo, [
{
scoreID: "SCORE_ID_4",
isNewScore: true,
},
{
scoreID: "SCORE_ID_5",
isNewScore: true,
},
{
scoreID: "SCORE_ID_6",
isNewScore: true,
},
]);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "SCORE_ID_1",
isNewScore: true,
},
{
scoreID: "SCORE_ID_2",
isNewScore: true,
},
{
scoreID: "SCORE_ID_3",
isNewScore: true,
},
]);
t.strictSame(sessions[1].scoreInfo, [
{
scoreID: "SCORE_ID_4",
isNewScore: true,
},
{
scoreID: "SCORE_ID_5",
isNewScore: true,
},
{
scoreID: "SCORE_ID_6",
isNewScore: true,
},
]);
t.end();
});
t.end();
});
t.test("Should append to existing sessions.", async (t) => {
await db.sessions.insert({
sessionID: "EXAMPLE_SESSION_ID",
userID: 1,
game: "iidx",
playtype: "SP",
importType: "ir/direct-manual",
timeStarted: start,
timeEnded: start,
scoreInfo: [
{
scoreID: "EXAMPLE_SCORE_ID",
isNewScore: true,
},
],
} as SessionDocument);
t.test("Should append to existing sessions.", async (t) => {
await db.sessions.insert({
sessionID: "EXAMPLE_SESSION_ID",
userID: 1,
game: "iidx",
playtype: "SP",
importType: "ir/direct-manual",
timeStarted: start,
timeEnded: start,
scoreInfo: [
{
scoreID: "EXAMPLE_SCORE_ID",
isNewScore: true,
},
],
} as SessionDocument);
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[null, start, start + 1000, start + 2000].map((e, i) =>
deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` })
) as ScoreDocument[],
"iidx",
"SP",
logger
);
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[null, start, start + 1000, start + 2000].map((e, i) =>
deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` })
) as ScoreDocument[],
"iidx",
"SP",
logger
);
t.strictSame(res, [
{
sessionID: "EXAMPLE_SESSION_ID",
type: "Appended",
},
]);
t.strictSame(res, [
{
sessionID: "EXAMPLE_SESSION_ID",
type: "Appended",
},
]);
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
t.equal(sessions.length, 1);
t.equal(sessions.length, 1);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "EXAMPLE_SCORE_ID",
isNewScore: true,
},
{
scoreID: "SCORE_ID_1",
isNewScore: true,
},
{
scoreID: "SCORE_ID_2",
isNewScore: true,
},
{
scoreID: "SCORE_ID_3",
isNewScore: true,
},
]);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "EXAMPLE_SCORE_ID",
isNewScore: true,
},
{
scoreID: "SCORE_ID_1",
isNewScore: true,
},
{
scoreID: "SCORE_ID_2",
isNewScore: true,
},
{
scoreID: "SCORE_ID_3",
isNewScore: true,
},
]);
t.equal(sessions[0].timeEnded, start + 2000);
t.equal(sessions[0].timeEnded, start + 2000);
t.end();
});
t.end();
});
t.test("Should change the timeStarted of existing sessions if prepending.", async (t) => {
await db.sessions.insert({
sessionID: "EXAMPLE_SESSION_ID",
userID: 1,
game: "iidx",
playtype: "SP",
importType: "ir/direct-manual",
timeStarted: start,
timeEnded: start,
scoreInfo: [
{
scoreID: "EXAMPLE_SCORE_ID",
isNewScore: true,
},
],
} as SessionDocument);
t.test("Should change the timeStarted of existing sessions if prepending.", async (t) => {
await db.sessions.insert({
sessionID: "EXAMPLE_SESSION_ID",
userID: 1,
game: "iidx",
playtype: "SP",
importType: "ir/direct-manual",
timeStarted: start,
timeEnded: start,
scoreInfo: [
{
scoreID: "EXAMPLE_SCORE_ID",
isNewScore: true,
},
],
} as SessionDocument);
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[null, start, start - 1000, start - 2000].map((e, i) =>
deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` })
) as ScoreDocument[],
"iidx",
"SP",
logger
);
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[null, start, start - 1000, start - 2000].map((e, i) =>
deepmerge(TestingIIDXSPScore, { timeAchieved: e, scoreID: `SCORE_ID_${i}` })
) as ScoreDocument[],
"iidx",
"SP",
logger
);
t.strictSame(res, [
{
sessionID: "EXAMPLE_SESSION_ID",
type: "Appended",
},
]);
t.strictSame(res, [
{
sessionID: "EXAMPLE_SESSION_ID",
type: "Appended",
},
]);
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
t.equal(sessions.length, 1);
t.equal(sessions.length, 1);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "EXAMPLE_SCORE_ID",
isNewScore: true,
},
{
scoreID: "SCORE_ID_3",
isNewScore: true,
},
{
scoreID: "SCORE_ID_2",
isNewScore: true,
},
{
scoreID: "SCORE_ID_1",
isNewScore: true,
},
]);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "EXAMPLE_SCORE_ID",
isNewScore: true,
},
{
scoreID: "SCORE_ID_3",
isNewScore: true,
},
{
scoreID: "SCORE_ID_2",
isNewScore: true,
},
{
scoreID: "SCORE_ID_1",
isNewScore: true,
},
]);
t.equal(sessions[0].timeStarted, start - 2000);
t.equal(sessions[0].timeStarted, start - 2000);
t.end();
});
t.end();
});
t.test("Should calculate pbDifferences if a scorePB exists.", async (t) => {
await db["personal-bests"].insert(TestingIIDXSPScorePB);
t.test("Should calculate pbDifferences if a scorePB exists.", async (t) => {
await db["personal-bests"].insert(TestingIIDXSPScorePB);
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[TestingIIDXSPScore],
"iidx",
"SP",
logger
);
const res = await LoadScoresIntoSessions(
1,
"ir/direct-manual",
[TestingIIDXSPScore],
"iidx",
"SP",
logger
);
t.match(res, [
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
]);
t.match(res, [
{
sessionID: /^Q[a-f0-9]{40}$/u,
type: "Created",
},
]);
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
const sessions = await db.sessions.find({ game: "iidx", playtype: "SP", userID: 1 });
t.equal(sessions.length, 1);
t.equal(sessions.length, 1);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "TESTING_SCORE_ID",
isNewScore: false,
gradeDelta: 4,
lampDelta: -2,
percentDelta: -44.08396946564885,
scoreDelta: -693,
},
]);
t.strictSame(sessions[0].scoreInfo, [
{
scoreID: "TESTING_SCORE_ID",
isNewScore: false,
gradeDelta: 4,
lampDelta: -2,
percentDelta: -44.08396946564885,
scoreDelta: -693,
},
]);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -1,13 +1,13 @@
import {
integer,
ImportTypes,
ScoreDocument,
SessionDocument,
Playtypes,
PBScoreDocument,
SessionScoreInfo,
SessionInfoReturn,
Game,
integer,
ImportTypes,
ScoreDocument,
SessionDocument,
Playtypes,
PBScoreDocument,
SessionScoreInfo,
SessionInfoReturn,
Game,
} from "tachi-common";
import db from "../../../../external/mongo/db";
import { AppendLogCtx, KtLogger } from "../../../logger/logger";
@@ -21,33 +21,33 @@ import { ScorePlaytypeMap } from "../common/types";
const TWO_HOURS = 1000 * 60 * 60 * 2;
export async function CreateSessions(
userID: integer,
importType: ImportTypes,
game: Game,
scorePtMap: ScorePlaytypeMap,
logger: KtLogger
userID: integer,
importType: ImportTypes,
game: Game,
scorePtMap: ScorePlaytypeMap,
logger: KtLogger
) {
const allSessionInfo = [];
const allSessionInfo = [];
/* eslint-disable no-await-in-loop */
for (const playtype in scorePtMap) {
// @ts-expect-error This is my least favourite thing about ts.
const scores = scorePtMap[playtype] as ScoreDocument[];
/* eslint-disable no-await-in-loop */
for (const playtype in scorePtMap) {
// @ts-expect-error This is my least favourite thing about ts.
const scores = scorePtMap[playtype] as ScoreDocument[];
const sessionInfo = await LoadScoresIntoSessions(
userID,
importType,
scores,
game,
playtype as Playtypes[Game],
logger
);
const sessionInfo = await LoadScoresIntoSessions(
userID,
importType,
scores,
game,
playtype as Playtypes[Game],
logger
);
allSessionInfo.push(...sessionInfo);
}
/* eslint-enable no-await-in-loop */
allSessionInfo.push(...sessionInfo);
}
/* eslint-enable no-await-in-loop */
return allSessionInfo;
return allSessionInfo;
}
/**
@@ -55,228 +55,228 @@ export async function CreateSessions(
* as a SessionScoreInfo object.
*/
function ProcessScoreIntoSessionScoreInfo(
score: ScoreDocument,
previousPB: PBScoreDocument | undefined
score: ScoreDocument,
previousPB: PBScoreDocument | undefined
): SessionScoreInfo {
if (!previousPB) {
return {
scoreID: score.scoreID,
isNewScore: true,
};
}
if (!previousPB) {
return {
scoreID: score.scoreID,
isNewScore: true,
};
}
return {
scoreID: score.scoreID,
isNewScore: false,
gradeDelta: score.scoreData.gradeIndex - previousPB.scoreData.gradeIndex,
lampDelta: score.scoreData.lampIndex - previousPB.scoreData.lampIndex,
percentDelta: score.scoreData.percent - previousPB.scoreData.percent,
scoreDelta: score.scoreData.score - previousPB.scoreData.score,
};
return {
scoreID: score.scoreID,
isNewScore: false,
gradeDelta: score.scoreData.gradeIndex - previousPB.scoreData.gradeIndex,
lampDelta: score.scoreData.lampIndex - previousPB.scoreData.lampIndex,
percentDelta: score.scoreData.percent - previousPB.scoreData.percent,
scoreDelta: score.scoreData.score - previousPB.scoreData.score,
};
}
function CreateSessionID() {
return `Q${crypto.randomBytes(20).toString("hex")}`;
return `Q${crypto.randomBytes(20).toString("hex")}`;
}
function UpdateExistingSession(
existingSession: SessionDocument,
newInfo: SessionScoreInfo[],
oldScores: ScoreDocument[],
newScores: ScoreDocument[]
existingSession: SessionDocument,
newInfo: SessionScoreInfo[],
oldScores: ScoreDocument[],
newScores: ScoreDocument[]
) {
const allScores = [...oldScores, ...newScores];
const allScores = [...oldScores, ...newScores];
const calculatedData = CreateSessionCalcData(
existingSession.game,
existingSession.playtype,
allScores
);
const calculatedData = CreateSessionCalcData(
existingSession.game,
existingSession.playtype,
allScores
);
existingSession.calculatedData = calculatedData;
existingSession.scoreInfo = [...existingSession.scoreInfo, ...newInfo];
existingSession.calculatedData = calculatedData;
existingSession.scoreInfo = [...existingSession.scoreInfo, ...newInfo];
if (newScores[0].timeAchieved! < existingSession.timeStarted) {
existingSession.timeStarted = newScores[0].timeAchieved!;
}
if (newScores[0].timeAchieved! < existingSession.timeStarted) {
existingSession.timeStarted = newScores[0].timeAchieved!;
}
if (newScores[newScores.length - 1].timeAchieved! > existingSession.timeEnded) {
existingSession.timeEnded = newScores[newScores.length - 1].timeAchieved!;
}
if (newScores[newScores.length - 1].timeAchieved! > existingSession.timeEnded) {
existingSession.timeEnded = newScores[newScores.length - 1].timeAchieved!;
}
return existingSession;
return existingSession;
}
function CreateSession(
userID: integer,
importType: ImportTypes,
groupInfo: SessionScoreInfo[],
groupScores: ScoreDocument[],
game: Game,
playtype: Playtypes[Game]
userID: integer,
importType: ImportTypes,
groupInfo: SessionScoreInfo[],
groupScores: ScoreDocument[],
game: Game,
playtype: Playtypes[Game]
): SessionDocument {
const name = GenerateRandomSessionName();
const name = GenerateRandomSessionName();
const calculatedData = CreateSessionCalcData(game, playtype, groupScores);
const calculatedData = CreateSessionCalcData(game, playtype, groupScores);
return {
userID,
importType,
name,
sessionID: CreateSessionID(),
desc: null,
game,
playtype,
highlight: false,
scoreInfo: groupInfo,
timeInserted: Date.now(),
timeStarted: groupScores[0].timeAchieved!,
timeEnded: groupScores[groupScores.length - 1].timeAchieved!,
calculatedData,
};
return {
userID,
importType,
name,
sessionID: CreateSessionID(),
desc: null,
game,
playtype,
highlight: false,
scoreInfo: groupInfo,
timeInserted: Date.now(),
timeStarted: groupScores[0].timeAchieved!,
timeEnded: groupScores[groupScores.length - 1].timeAchieved!,
calculatedData,
};
}
export async function LoadScoresIntoSessions(
userID: integer,
importType: ImportTypes,
importScores: ScoreDocument[],
game: Game,
playtype: Playtypes[Game],
baseLogger: KtLogger
userID: integer,
importType: ImportTypes,
importScores: ScoreDocument[],
game: Game,
playtype: Playtypes[Game],
baseLogger: KtLogger
): Promise<SessionInfoReturn[]> {
const logger = AppendLogCtx("Session Generation", baseLogger);
const logger = AppendLogCtx("Session Generation", baseLogger);
const timestampedScores = [];
const timestampedScores = [];
for (const score of importScores) {
if (!score.timeAchieved) {
logger.verbose(`Ignored score ${score.scoreID}, as it had no timeAchieved.`);
// ignore scores without timestamps. We can't use these for sessions.
continue;
}
for (const score of importScores) {
if (!score.timeAchieved) {
logger.verbose(`Ignored score ${score.scoreID}, as it had no timeAchieved.`);
// ignore scores without timestamps. We can't use these for sessions.
continue;
}
timestampedScores.push(score);
}
timestampedScores.push(score);
}
// If we have nothing to work with, why bother?
if (timestampedScores.length === 0) {
logger.verbose(`Skipped calculating sessions as there were no timestamped scores`);
return [];
}
// If we have nothing to work with, why bother?
if (timestampedScores.length === 0) {
logger.verbose(`Skipped calculating sessions as there were no timestamped scores`);
return [];
}
// sort scores ascendingly.
timestampedScores.sort((a, b) => a.timeAchieved! - b.timeAchieved!);
// sort scores ascendingly.
timestampedScores.sort((a, b) => a.timeAchieved! - b.timeAchieved!);
// The "Score Groups" for the array of scores provided.
// This contains scores split on 2hr margins, which allows for more optimised
// session db requests.
const sessionScoreGroups: ScoreDocument[][] = [];
let curGroup: ScoreDocument[] = [];
let lastTimestamp = 0;
// The "Score Groups" for the array of scores provided.
// This contains scores split on 2hr margins, which allows for more optimised
// session db requests.
const sessionScoreGroups: ScoreDocument[][] = [];
let curGroup: ScoreDocument[] = [];
let lastTimestamp = 0;
for (const score of timestampedScores) {
if (score.timeAchieved! < lastTimestamp + TWO_HOURS) {
curGroup.push(score);
} else {
sessionScoreGroups.push(curGroup);
curGroup = [score];
}
lastTimestamp = score.timeAchieved!;
}
for (const score of timestampedScores) {
if (score.timeAchieved! < lastTimestamp + TWO_HOURS) {
curGroup.push(score);
} else {
sessionScoreGroups.push(curGroup);
curGroup = [score];
}
lastTimestamp = score.timeAchieved!;
}
// There's no state here where curGroup is empty,
// so push the group (which is guaranteed to have atleast one score)
sessionScoreGroups.push(curGroup);
// There's no state here where curGroup is empty,
// so push the group (which is guaranteed to have atleast one score)
sessionScoreGroups.push(curGroup);
logger.verbose(`Created ${sessionScoreGroups.length} groups from timestamped scores.`);
logger.verbose(`Created ${sessionScoreGroups.length} groups from timestamped scores.`);
const sessionInfoReturns: SessionInfoReturn[] = [];
const sessionInfoReturns: SessionInfoReturn[] = [];
// All async operations inside here *need* to be done in lockstep to avoid colliding sessions.
// realistically, that shouldn't be possible, but hey.
/* eslint-disable no-await-in-loop */
for (const groupScores of sessionScoreGroups) {
if (groupScores.length === 0) {
continue;
}
// All async operations inside here *need* to be done in lockstep to avoid colliding sessions.
// realistically, that shouldn't be possible, but hey.
/* eslint-disable no-await-in-loop */
for (const groupScores of sessionScoreGroups) {
if (groupScores.length === 0) {
continue;
}
const startOfGroup = groupScores[0].timeAchieved!;
const endOfGroup = groupScores[groupScores.length - 1].timeAchieved!;
const startOfGroup = groupScores[0].timeAchieved!;
const endOfGroup = groupScores[groupScores.length - 1].timeAchieved!;
// A bug exists here where if a session is created
// backwards in time, this will grab your PBs
// from the future.
// @todo #179
const pbs = await db["personal-bests"].find({
chartID: { $in: groupScores.map((e) => e.chartID) },
userID,
});
// A bug exists here where if a session is created
// backwards in time, this will grab your PBs
// from the future.
// @todo #179
const pbs = await db["personal-bests"].find({
chartID: { $in: groupScores.map((e) => e.chartID) },
userID,
});
const pbMap: Map<string, PBScoreDocument> = new Map();
for (const pb of pbs) {
pbMap.set(pb.chartID, pb);
}
const pbMap: Map<string, PBScoreDocument> = new Map();
for (const pb of pbs) {
pbMap.set(pb.chartID, pb);
}
const groupInfo = groupScores.map((e) =>
ProcessScoreIntoSessionScoreInfo(e, pbMap.get(e.chartID))
);
const groupInfo = groupScores.map((e) =>
ProcessScoreIntoSessionScoreInfo(e, pbMap.get(e.chartID))
);
// Find any sessions with +/-2hrs of this group. This is rather exhaustive, and could result in some issues
// if this query returns more than one session. We should account for that by smushing sessions together.
// As of now, we dont currently do it. @TODO #148.
const nearbySession = await db.sessions.findOne({
userID,
game,
playtype,
importType,
$or: [
{ timeStarted: { $gte: startOfGroup - TWO_HOURS, $lt: endOfGroup + TWO_HOURS } },
{ timeEnded: { $gte: startOfGroup - TWO_HOURS, $lt: endOfGroup + TWO_HOURS } },
],
});
// Find any sessions with +/-2hrs of this group. This is rather exhaustive, and could result in some issues
// if this query returns more than one session. We should account for that by smushing sessions together.
// As of now, we dont currently do it. @TODO #148.
const nearbySession = await db.sessions.findOne({
userID,
game,
playtype,
importType,
$or: [
{ timeStarted: { $gte: startOfGroup - TWO_HOURS, $lt: endOfGroup + TWO_HOURS } },
{ timeEnded: { $gte: startOfGroup - TWO_HOURS, $lt: endOfGroup + TWO_HOURS } },
],
});
let infoReturn: SessionInfoReturn;
let infoReturn: SessionInfoReturn;
if (nearbySession) {
logger.verbose(
`Found nearby session for ${userID} (${game} ${playtype}) around ${startOfGroup} ${endOfGroup}.`
);
if (nearbySession) {
logger.verbose(
`Found nearby session for ${userID} (${game} ${playtype}) around ${startOfGroup} ${endOfGroup}.`
);
const oldScores = await GetScoresFromSession(nearbySession);
const oldScores = await GetScoresFromSession(nearbySession);
const session = UpdateExistingSession(nearbySession, groupInfo, oldScores, groupScores);
const session = UpdateExistingSession(nearbySession, groupInfo, oldScores, groupScores);
infoReturn = { sessionID: session.sessionID, type: "Appended" };
infoReturn = { sessionID: session.sessionID, type: "Appended" };
await db.sessions.update(
{
sessionID: session.sessionID,
},
{
$set: session,
}
);
} else {
logger.debug(
`Creating new session for ${userID} (${game} ${playtype}) around ${startOfGroup} ${endOfGroup}.`
);
await db.sessions.update(
{
sessionID: session.sessionID,
},
{
$set: session,
}
);
} else {
logger.debug(
`Creating new session for ${userID} (${game} ${playtype}) around ${startOfGroup} ${endOfGroup}.`
);
const session = CreateSession(
userID,
importType,
groupInfo,
groupScores,
game,
playtype
);
const session = CreateSession(
userID,
importType,
groupInfo,
groupScores,
game,
playtype
);
infoReturn = { sessionID: session.sessionID, type: "Created" };
await db.sessions.insert(session);
}
infoReturn = { sessionID: session.sessionID, type: "Created" };
await db.sessions.insert(session);
}
sessionInfoReturns.push(infoReturn);
}
/* eslint-enable no-await-in-loop */
sessionInfoReturns.push(infoReturn);
}
/* eslint-enable no-await-in-loop */
return sessionInfoReturns;
return sessionInfoReturns;
}
@@ -2,106 +2,106 @@ import t from "tap";
import { GitadoraColours, SDVXVFClasses } from "../../../constants/classes";
import CreateLogCtx from "../../../logger/logger";
import {
CalculateGitadoraColour,
GitadoraSkillToColour,
CalculateSDVXClass,
SDVXVF6ToClass,
CalculateGitadoraColour,
GitadoraSkillToColour,
CalculateSDVXClass,
SDVXVF6ToClass,
} from "./builtin-class-handlers";
const logger = CreateLogCtx(__filename);
t.test("#CalculateGitadoraColour", (t) => {
t.strictSame(
CalculateGitadoraColour("gitadora", "Gita", 1, { skill: 1500 }, logger),
{
colour: GitadoraColours.ORANGE_GRADIENT,
},
"Should wrap the skill colour."
);
t.strictSame(
CalculateGitadoraColour("gitadora", "Gita", 1, { skill: 1500 }, logger),
{
colour: GitadoraColours.ORANGE_GRADIENT,
},
"Should wrap the skill colour."
);
t.end();
t.end();
});
t.test("#GitadoraSkillToColour", (t) => {
t.equal(GitadoraSkillToColour(0), GitadoraColours.WHITE);
t.equal(GitadoraSkillToColour(999), GitadoraColours.WHITE);
t.equal(GitadoraSkillToColour(1000), GitadoraColours.ORANGE);
t.equal(GitadoraSkillToColour(1500), GitadoraColours.ORANGE_GRADIENT);
t.equal(GitadoraSkillToColour(2000), GitadoraColours.YELLOW);
t.equal(GitadoraSkillToColour(2500), GitadoraColours.YELLOW_GRADIENT);
t.equal(GitadoraSkillToColour(3000), GitadoraColours.GREEN);
t.equal(GitadoraSkillToColour(3500), GitadoraColours.GREEN_GRADIENT);
t.equal(GitadoraSkillToColour(4000), GitadoraColours.BLUE);
t.equal(GitadoraSkillToColour(4500), GitadoraColours.BLUE_GRADIENT);
t.equal(GitadoraSkillToColour(5000), GitadoraColours.PURPLE);
t.equal(GitadoraSkillToColour(5500), GitadoraColours.PURPLE_GRADIENT);
t.equal(GitadoraSkillToColour(6000), GitadoraColours.RED);
t.equal(GitadoraSkillToColour(6500), GitadoraColours.RED_GRADIENT);
t.equal(GitadoraSkillToColour(7000), GitadoraColours.BRONZE);
t.equal(GitadoraSkillToColour(7500), GitadoraColours.SILVER);
t.equal(GitadoraSkillToColour(8000), GitadoraColours.GOLD);
t.equal(GitadoraSkillToColour(8500), GitadoraColours.RAINBOW);
t.equal(GitadoraSkillToColour(0), GitadoraColours.WHITE);
t.equal(GitadoraSkillToColour(999), GitadoraColours.WHITE);
t.equal(GitadoraSkillToColour(1000), GitadoraColours.ORANGE);
t.equal(GitadoraSkillToColour(1500), GitadoraColours.ORANGE_GRADIENT);
t.equal(GitadoraSkillToColour(2000), GitadoraColours.YELLOW);
t.equal(GitadoraSkillToColour(2500), GitadoraColours.YELLOW_GRADIENT);
t.equal(GitadoraSkillToColour(3000), GitadoraColours.GREEN);
t.equal(GitadoraSkillToColour(3500), GitadoraColours.GREEN_GRADIENT);
t.equal(GitadoraSkillToColour(4000), GitadoraColours.BLUE);
t.equal(GitadoraSkillToColour(4500), GitadoraColours.BLUE_GRADIENT);
t.equal(GitadoraSkillToColour(5000), GitadoraColours.PURPLE);
t.equal(GitadoraSkillToColour(5500), GitadoraColours.PURPLE_GRADIENT);
t.equal(GitadoraSkillToColour(6000), GitadoraColours.RED);
t.equal(GitadoraSkillToColour(6500), GitadoraColours.RED_GRADIENT);
t.equal(GitadoraSkillToColour(7000), GitadoraColours.BRONZE);
t.equal(GitadoraSkillToColour(7500), GitadoraColours.SILVER);
t.equal(GitadoraSkillToColour(8000), GitadoraColours.GOLD);
t.equal(GitadoraSkillToColour(8500), GitadoraColours.RAINBOW);
t.end();
t.end();
});
t.test("#CalculateSDVXClass", (t) => {
t.strictSame(CalculateSDVXClass("sdvx", "Single", 1, { VF6: 10 }, logger), {
vfClass: SDVXVFClasses.COBALT_I,
});
t.strictSame(CalculateSDVXClass("sdvx", "Single", 1, { VF6: 10 }, logger), {
vfClass: SDVXVFClasses.COBALT_I,
});
t.end();
t.end();
});
t.test("#SDVXVF6ToClass", (t) => {
function f(vf: number) {
return SDVXVF6ToClass(vf, logger);
}
function f(vf: number) {
return SDVXVF6ToClass(vf, logger);
}
t.equal(f(0), SDVXVFClasses.SIENNA_I);
t.equal(f(2.5), SDVXVFClasses.SIENNA_II);
t.equal(f(5), SDVXVFClasses.SIENNA_III);
t.equal(f(7.5), SDVXVFClasses.SIENNA_IV);
t.equal(f(10), SDVXVFClasses.COBALT_I);
t.equal(f(10.5), SDVXVFClasses.COBALT_II);
t.equal(f(11), SDVXVFClasses.COBALT_III);
t.equal(f(11.5), SDVXVFClasses.COBALT_IV);
t.equal(f(12), SDVXVFClasses.DANDELION_I);
t.equal(f(12.5), SDVXVFClasses.DANDELION_II);
t.equal(f(13), SDVXVFClasses.DANDELION_III);
t.equal(f(13.5), SDVXVFClasses.DANDELION_IV);
t.equal(f(0), SDVXVFClasses.SIENNA_I);
t.equal(f(2.5), SDVXVFClasses.SIENNA_II);
t.equal(f(5), SDVXVFClasses.SIENNA_III);
t.equal(f(7.5), SDVXVFClasses.SIENNA_IV);
t.equal(f(10), SDVXVFClasses.COBALT_I);
t.equal(f(10.5), SDVXVFClasses.COBALT_II);
t.equal(f(11), SDVXVFClasses.COBALT_III);
t.equal(f(11.5), SDVXVFClasses.COBALT_IV);
t.equal(f(12), SDVXVFClasses.DANDELION_I);
t.equal(f(12.5), SDVXVFClasses.DANDELION_II);
t.equal(f(13), SDVXVFClasses.DANDELION_III);
t.equal(f(13.5), SDVXVFClasses.DANDELION_IV);
t.equal(f(14), SDVXVFClasses.CYAN_I);
t.equal(f(14.25), SDVXVFClasses.CYAN_II);
t.equal(f(14.5), SDVXVFClasses.CYAN_III);
t.equal(f(14.75), SDVXVFClasses.CYAN_IV);
t.equal(f(15), SDVXVFClasses.SCARLET_I);
t.equal(f(15.25), SDVXVFClasses.SCARLET_II);
t.equal(f(15.5), SDVXVFClasses.SCARLET_III);
t.equal(f(15.75), SDVXVFClasses.SCARLET_IV);
t.equal(f(16), SDVXVFClasses.CORAL_I);
t.equal(f(16.25), SDVXVFClasses.CORAL_II);
t.equal(f(16.5), SDVXVFClasses.CORAL_III);
t.equal(f(16.75), SDVXVFClasses.CORAL_IV);
t.equal(f(17), SDVXVFClasses.ARGENTO_I);
t.equal(f(17.25), SDVXVFClasses.ARGENTO_II);
t.equal(f(17.5), SDVXVFClasses.ARGENTO_III);
t.equal(f(17.75), SDVXVFClasses.ARGENTO_IV);
t.equal(f(18), SDVXVFClasses.ELDORA_I);
t.equal(f(18.25), SDVXVFClasses.ELDORA_II);
t.equal(f(18.5), SDVXVFClasses.ELDORA_III);
t.equal(f(18.75), SDVXVFClasses.ELDORA_IV);
t.equal(f(19), SDVXVFClasses.CRIMSON_I);
t.equal(f(19.25), SDVXVFClasses.CRIMSON_II);
t.equal(f(19.5), SDVXVFClasses.CRIMSON_III);
t.equal(f(19.75), SDVXVFClasses.CRIMSON_IV);
t.equal(f(14), SDVXVFClasses.CYAN_I);
t.equal(f(14.25), SDVXVFClasses.CYAN_II);
t.equal(f(14.5), SDVXVFClasses.CYAN_III);
t.equal(f(14.75), SDVXVFClasses.CYAN_IV);
t.equal(f(15), SDVXVFClasses.SCARLET_I);
t.equal(f(15.25), SDVXVFClasses.SCARLET_II);
t.equal(f(15.5), SDVXVFClasses.SCARLET_III);
t.equal(f(15.75), SDVXVFClasses.SCARLET_IV);
t.equal(f(16), SDVXVFClasses.CORAL_I);
t.equal(f(16.25), SDVXVFClasses.CORAL_II);
t.equal(f(16.5), SDVXVFClasses.CORAL_III);
t.equal(f(16.75), SDVXVFClasses.CORAL_IV);
t.equal(f(17), SDVXVFClasses.ARGENTO_I);
t.equal(f(17.25), SDVXVFClasses.ARGENTO_II);
t.equal(f(17.5), SDVXVFClasses.ARGENTO_III);
t.equal(f(17.75), SDVXVFClasses.ARGENTO_IV);
t.equal(f(18), SDVXVFClasses.ELDORA_I);
t.equal(f(18.25), SDVXVFClasses.ELDORA_II);
t.equal(f(18.5), SDVXVFClasses.ELDORA_III);
t.equal(f(18.75), SDVXVFClasses.ELDORA_IV);
t.equal(f(19), SDVXVFClasses.CRIMSON_I);
t.equal(f(19.25), SDVXVFClasses.CRIMSON_II);
t.equal(f(19.5), SDVXVFClasses.CRIMSON_III);
t.equal(f(19.75), SDVXVFClasses.CRIMSON_IV);
t.equal(f(20), SDVXVFClasses.IMPERIAL_I);
t.equal(f(21), SDVXVFClasses.IMPERIAL_II);
t.equal(f(22), SDVXVFClasses.IMPERIAL_III);
t.equal(f(23), SDVXVFClasses.IMPERIAL_IV);
t.equal(f(24), SDVXVFClasses.IMPERIAL_IV);
t.equal(f(1000), SDVXVFClasses.IMPERIAL_IV);
t.equal(f(20), SDVXVFClasses.IMPERIAL_I);
t.equal(f(21), SDVXVFClasses.IMPERIAL_II);
t.equal(f(22), SDVXVFClasses.IMPERIAL_III);
t.equal(f(23), SDVXVFClasses.IMPERIAL_IV);
t.equal(f(24), SDVXVFClasses.IMPERIAL_IV);
t.equal(f(1000), SDVXVFClasses.IMPERIAL_IV);
t.end();
t.end();
});
@@ -4,17 +4,17 @@ import { GitadoraColours, SDVXVFClasses } from "../../../constants/classes";
import { KtLogger } from "../../../logger/logger";
export function CalculateSDVXClass(
game: Game,
playtype: Playtypes[Game],
userID: integer,
ratings: Partial<Record<ScoreCalculatedDataLookup["sdvx:Single"], number>>,
logger: KtLogger
game: Game,
playtype: Playtypes[Game],
userID: integer,
ratings: Partial<Record<ScoreCalculatedDataLookup["sdvx:Single"], number>>,
logger: KtLogger
): Partial<GameClasses<"sdvx:Single">> {
if (!ratings.VF6) {
return {};
}
if (!ratings.VF6) {
return {};
}
return { vfClass: SDVXVF6ToClass(ratings.VF6, logger) };
return { vfClass: SDVXVF6ToClass(ratings.VF6, logger) };
}
/**
@@ -24,83 +24,83 @@ export function CalculateSDVXClass(
* @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 VF5 of ${vf}. Defaulting to Imperial IV.`);
return SDVXVFClasses.IMPERIAL_IV;
} else if (vf >= 20) {
// imperial i -> iv has gaps of 1
return SDVXVFClasses.IMPERIAL_I + Math.floor(vf - 20);
} else if (vf >= 14) {
// cyan i -> crimson iv has gaps of 0.25
return SDVXVFClasses.CYAN_I + Math.floor(4 * (vf - 14));
} else if (vf >= 10) {
// cobalt i -> dandelion iv have gaps of 0.5
return SDVXVFClasses.COBALT_I + Math.floor(2 * (vf - 10));
}
// This is impossible, but a failsafe regardless
if (vf >= 24) {
logger.warn(`User has excessive VF5 of ${vf}. Defaulting to Imperial IV.`);
return SDVXVFClasses.IMPERIAL_IV;
} else if (vf >= 20) {
// imperial i -> iv has gaps of 1
return SDVXVFClasses.IMPERIAL_I + Math.floor(vf - 20);
} else if (vf >= 14) {
// cyan i -> crimson iv has gaps of 0.25
return SDVXVFClasses.CYAN_I + Math.floor(4 * (vf - 14));
} else if (vf >= 10) {
// cobalt i -> dandelion iv have gaps of 0.5
return SDVXVFClasses.COBALT_I + Math.floor(2 * (vf - 10));
}
return Math.floor(vf / 2.5);
return Math.floor(vf / 2.5);
}
export function CalculateGitadoraColour(
game: Game,
playtype: Playtypes[Game],
userID: integer,
ratings: Record<string, number>,
logger: KtLogger
game: Game,
playtype: Playtypes[Game],
userID: integer,
ratings: Record<string, number>,
logger: KtLogger
): Partial<GameClasses<"gitadora:Dora" | "gitadora:Gita">> {
const colour = GitadoraSkillToColour(ratings.skill);
const colour = GitadoraSkillToColour(ratings.skill);
return {
colour,
};
return {
colour,
};
}
export function GitadoraSkillToColour(sk: number) {
if (sk >= 8500) {
return GitadoraColours.RAINBOW;
} else if (sk >= 8000) {
return GitadoraColours.GOLD;
} else if (sk >= 7500) {
return GitadoraColours.SILVER;
} else if (sk >= 7000) {
return GitadoraColours.BRONZE;
} else if (sk >= 6500) {
return GitadoraColours.RED_GRADIENT;
} else if (sk >= 6000) {
return GitadoraColours.RED;
} else if (sk >= 5500) {
return GitadoraColours.PURPLE_GRADIENT;
} else if (sk >= 5000) {
return GitadoraColours.PURPLE;
} else if (sk >= 4500) {
return GitadoraColours.BLUE_GRADIENT;
} else if (sk >= 4000) {
return GitadoraColours.BLUE;
} else if (sk >= 3500) {
return GitadoraColours.GREEN_GRADIENT;
} else if (sk >= 3000) {
return GitadoraColours.GREEN;
} else if (sk >= 2500) {
return GitadoraColours.YELLOW_GRADIENT;
} else if (sk >= 2000) {
return GitadoraColours.YELLOW;
} else if (sk >= 1500) {
return GitadoraColours.ORANGE_GRADIENT;
} else if (sk >= 1000) {
return GitadoraColours.ORANGE;
}
if (sk >= 8500) {
return GitadoraColours.RAINBOW;
} else if (sk >= 8000) {
return GitadoraColours.GOLD;
} else if (sk >= 7500) {
return GitadoraColours.SILVER;
} else if (sk >= 7000) {
return GitadoraColours.BRONZE;
} else if (sk >= 6500) {
return GitadoraColours.RED_GRADIENT;
} else if (sk >= 6000) {
return GitadoraColours.RED;
} else if (sk >= 5500) {
return GitadoraColours.PURPLE_GRADIENT;
} else if (sk >= 5000) {
return GitadoraColours.PURPLE;
} else if (sk >= 4500) {
return GitadoraColours.BLUE_GRADIENT;
} else if (sk >= 4000) {
return GitadoraColours.BLUE;
} else if (sk >= 3500) {
return GitadoraColours.GREEN_GRADIENT;
} else if (sk >= 3000) {
return GitadoraColours.GREEN;
} else if (sk >= 2500) {
return GitadoraColours.YELLOW_GRADIENT;
} else if (sk >= 2000) {
return GitadoraColours.YELLOW;
} else if (sk >= 1500) {
return GitadoraColours.ORANGE_GRADIENT;
} else if (sk >= 1000) {
return GitadoraColours.ORANGE;
}
return GitadoraColours.WHITE;
return GitadoraColours.WHITE;
}
export function CalculateJubeatColour(
game: Game,
playtype: Playtypes[Game],
userID: integer,
ratings: Record<string, number>
game: Game,
playtype: Playtypes[Game],
userID: integer,
ratings: Record<string, number>
): GameClasses<"jubeat:Single"> {
throw new Error("Not implemented.");
throw new Error("Not implemented.");
}
// function JubilityToColour(jb: number) {}
@@ -9,131 +9,131 @@ import { GitadoraColours } from "../../../constants/classes";
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);
t.test("Should produce an empty object by default", async (t) => {
const res = await UpdateUGSClasses("iidx", "SP", 1, {}, null, logger);
t.strictSame(res, {});
t.strictSame(res, {});
t.end();
});
t.end();
});
t.test("Should call and merge the ClassHandler", async (t) => {
const res = await UpdateUGSClasses("iidx", "SP", 1, {}, () => ({ dan: 2 }), logger);
t.test("Should call and merge the ClassHandler", async (t) => {
const res = await UpdateUGSClasses("iidx", "SP", 1, {}, () => ({ dan: 2 }), logger);
t.strictSame(res, { dan: 2 });
t.strictSame(res, { dan: 2 });
t.end();
});
t.end();
});
t.test("Should call static handlers if there is one", async (t) => {
const res = await UpdateUGSClasses(
"gitadora",
"Dora",
1,
{
skill: 9000,
},
null,
logger
);
t.test("Should call static handlers if there is one", async (t) => {
const res = await UpdateUGSClasses(
"gitadora",
"Dora",
1,
{
skill: 9000,
},
null,
logger
);
t.strictSame(res, { colour: GitadoraColours.RAINBOW });
t.strictSame(res, { colour: GitadoraColours.RAINBOW });
t.end();
});
t.end();
});
t.end();
t.end();
});
t.test("#CalculateClassDeltas", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should return improved classes from null", (t) => {
const res = CalculateClassDeltas("SP", { dan: 18 }, null, 1, logger);
t.test("Should return improved classes from null", (t) => {
const res = CalculateClassDeltas("SP", { dan: 18 }, null, 1, logger);
t.strictSame(res, [
{
set: "dan",
playtype: "SP",
old: null,
new: 18,
},
]);
t.strictSame(res, [
{
set: "dan",
playtype: "SP",
old: null,
new: 18,
},
]);
t.end();
});
t.end();
});
t.test("Should return improved classes from null class", (t) => {
const res = CalculateClassDeltas(
"SP",
{ dan: 18 },
{ classes: {} } as UserGameStats,
1,
logger
);
t.test("Should return improved classes from null class", (t) => {
const res = CalculateClassDeltas(
"SP",
{ dan: 18 },
{ classes: {} } as UserGameStats,
1,
logger
);
t.strictSame(res, [
{
set: "dan",
playtype: "SP",
old: null,
new: 18,
},
]);
t.strictSame(res, [
{
set: "dan",
playtype: "SP",
old: null,
new: 18,
},
]);
t.end();
});
t.end();
});
t.test("Should return improved classes", (t) => {
const res = CalculateClassDeltas(
"SP",
{ dan: 18 },
{ classes: { dan: 17 } } as unknown as UserGameStats,
1,
logger
);
t.test("Should return improved classes", (t) => {
const res = CalculateClassDeltas(
"SP",
{ dan: 18 },
{ classes: { dan: 17 } } as unknown as UserGameStats,
1,
logger
);
t.strictSame(res, [
{
set: "dan",
playtype: "SP",
old: 17,
new: 18,
},
]);
t.strictSame(res, [
{
set: "dan",
playtype: "SP",
old: 17,
new: 18,
},
]);
t.end();
});
t.end();
});
t.test("Should not return identical classes", (t) => {
const res = CalculateClassDeltas(
"SP",
{ dan: 18 },
{ classes: { dan: 18 } } as unknown as UserGameStats,
1,
logger
);
t.test("Should not return identical classes", (t) => {
const res = CalculateClassDeltas(
"SP",
{ dan: 18 },
{ classes: { dan: 18 } } as unknown as UserGameStats,
1,
logger
);
t.strictSame(res, []);
t.strictSame(res, []);
t.end();
});
t.end();
});
t.test("Should not return worse classes", (t) => {
const res = CalculateClassDeltas(
"SP",
{ dan: 16 },
{ classes: { dan: 18 } } as unknown as UserGameStats,
1,
logger
);
t.test("Should not return worse classes", (t) => {
const res = CalculateClassDeltas(
"SP",
{ dan: 16 },
{ classes: { dan: 18 } } as unknown as UserGameStats,
1,
logger
);
t.strictSame(res, []);
t.strictSame(res, []);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -3,20 +3,20 @@ import { GameClasses } from "tachi-common/js/game-classes";
import deepmerge from "deepmerge";
import { KtLogger } from "../../../logger/logger";
import {
CalculateGitadoraColour,
CalculateJubeatColour,
CalculateSDVXClass,
CalculateGitadoraColour,
CalculateJubeatColour,
CalculateSDVXClass,
} from "./builtin-class-handlers";
import { ReturnClassIfGreater } from "../../../../utils/class";
import { RedisPub } from "../../../../external/redis/redis-IPC";
import { ClassHandler, ScoreClasses } from "./types";
type ClassHandlerMap = {
[G in Game]:
| {
[P in Playtypes[G]]: ClassHandler;
}
| null;
[G in Game]:
| {
[P in Playtypes[G]]: ClassHandler;
}
| null;
};
/**
@@ -29,24 +29,24 @@ type ClassHandlerMap = {
* 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: null,
ddr: null,
gitadora: {
Gita: CalculateGitadoraColour,
Dora: CalculateGitadoraColour,
},
jubeat: {
Single: CalculateJubeatColour,
},
maimai: null,
museca: null,
popn: null,
sdvx: {
Single: CalculateSDVXClass,
},
usc: null,
iidx: null,
bms: null,
chunithm: null,
ddr: null,
gitadora: {
Gita: CalculateGitadoraColour,
Dora: CalculateGitadoraColour,
},
jubeat: {
Single: CalculateJubeatColour,
},
maimai: null,
museca: null,
popn: null,
sdvx: {
Single: CalculateSDVXClass,
},
usc: null,
};
/**
@@ -71,35 +71,35 @@ const STATIC_CLASS_HANDLERS: ClassHandlerMap = {
* defaults.
*/
export async function UpdateUGSClasses(
game: Game,
playtype: Playtypes[Game],
userID: integer,
ratings: Record<string, number>,
ClassHandler: ClassHandler | null,
logger: KtLogger
game: Game,
playtype: Playtypes[Game],
userID: integer,
ratings: Record<string, number>,
ClassHandler: ClassHandler | null,
logger: KtLogger
): Promise<ScoreClasses> {
let classes: ScoreClasses = {};
let classes: ScoreClasses = {};
// @ts-expect-error This one sucks - I need to look into a better way of representing these types
if (STATIC_CLASS_HANDLERS[game] && STATIC_CLASS_HANDLERS[game][playtype]) {
// @ts-expect-error see above
classes = await STATIC_CLASS_HANDLERS[game][playtype](
game,
playtype,
userID,
ratings,
logger
);
}
// @ts-expect-error This one sucks - I need to look into a better way of representing these types
if (STATIC_CLASS_HANDLERS[game] && STATIC_CLASS_HANDLERS[game][playtype]) {
// @ts-expect-error see above
classes = await STATIC_CLASS_HANDLERS[game][playtype](
game,
playtype,
userID,
ratings,
logger
);
}
if (ClassHandler) {
logger.debug(`Calling custom class handler.`);
const customClasses = (await ClassHandler(game, playtype, userID, ratings, logger)) ?? {};
if (ClassHandler) {
logger.debug(`Calling custom class handler.`);
const customClasses = (await ClassHandler(game, playtype, userID, ratings, logger)) ?? {};
classes = deepmerge(customClasses, classes);
}
classes = deepmerge(customClasses, classes);
}
return classes;
return classes;
}
/**
@@ -110,54 +110,54 @@ export async function UpdateUGSClasses(
* so that other services can listen for it. In the future we might allow webhooks, too.
*/
export function CalculateClassDeltas(
playtype: Playtypes[Game],
classes: ScoreClasses,
userGameStats: UserGameStats | null,
userID: integer,
logger: KtLogger
playtype: Playtypes[Game],
classes: ScoreClasses,
userGameStats: UserGameStats | null,
userID: integer,
logger: KtLogger
): ClassDelta[] {
const deltas: ClassDelta[] = [];
const deltas: ClassDelta[] = [];
for (const s in classes) {
const classSet = s as keyof GameClasses<IDStrings>;
const classVal = classes[classSet];
for (const s in classes) {
const classSet = s as keyof GameClasses<IDStrings>;
const classVal = classes[classSet];
if (classVal === undefined) {
logger.debug(`Skipped deltaing-class ${classSet}.`);
continue;
}
if (classVal === undefined) {
logger.debug(`Skipped deltaing-class ${classSet}.`);
continue;
}
try {
const isGreater = ReturnClassIfGreater(classSet, classVal, userGameStats);
try {
const isGreater = ReturnClassIfGreater(classSet, classVal, userGameStats);
if (isGreater === false) {
continue;
} else {
let delta: ClassDelta;
if (isGreater === null) {
delta = {
set: classSet,
playtype,
old: null,
new: classVal,
};
} else {
delta = {
set: classSet,
playtype,
old: userGameStats!.classes[classSet]!,
new: classVal,
};
}
if (isGreater === false) {
continue;
} else {
let delta: ClassDelta;
if (isGreater === null) {
delta = {
set: classSet,
playtype,
old: null,
new: classVal,
};
} else {
delta = {
set: classSet,
playtype,
old: userGameStats!.classes[classSet]!,
new: classVal,
};
}
RedisPub("class-update", { userID, ...delta });
RedisPub("class-update", { userID, ...delta });
deltas.push(delta);
}
} catch (err) {
logger.error(err);
}
}
deltas.push(delta);
}
} catch (err) {
logger.error(err);
}
}
return deltas;
return deltas;
}
@@ -7,77 +7,77 @@ import { CloseAllConnections } from "../../../../test-utils/close-connections";
const logger = CreateLogCtx(__filename);
t.test("#CalculateRatings", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should work for games with no custom ratings", async (t) => {
const res = await CalculateRatings("popn", "9B", 1, logger);
t.test("Should work for games with no custom ratings", async (t) => {
const res = await CalculateRatings("popn", "9B", 1, logger);
t.strictSame(res, {}, "Should return an empty object.");
t.strictSame(res, {}, "Should return an empty object.");
t.end();
});
t.end();
});
t.test("Should return BPI for IIDX", async (t) => {
const res = await CalculateRatings("iidx", "SP", 1, logger);
t.test("Should return BPI for IIDX", async (t) => {
const res = await CalculateRatings("iidx", "SP", 1, logger);
t.strictSame(
res,
{ BPI: 0, ktRating: 0, ktLampRating: 0 },
"Should return BPI as a custom key."
);
t.strictSame(
res,
{ BPI: 0, ktRating: 0, ktLampRating: 0 },
"Should return BPI as a custom key."
);
const resDP = await CalculateRatings("iidx", "DP", 1, logger);
const resDP = await CalculateRatings("iidx", "DP", 1, logger);
t.strictSame(
resDP,
{ BPI: 0, ktRating: 0, ktLampRating: 0 },
"Should return BPI as a custom key."
);
t.strictSame(
resDP,
{ BPI: 0, ktRating: 0, ktLampRating: 0 },
"Should return BPI as a custom key."
);
t.end();
});
t.end();
});
t.test("Should return VF4 and VF5 for SDVX", async (t) => {
const res = await CalculateRatings("sdvx", "Single", 1, logger);
t.test("Should return VF4 and VF5 for SDVX", async (t) => {
const res = await CalculateRatings("sdvx", "Single", 1, logger);
t.strictSame(res, { VF6: 0 }, "Should return VF4 and VF5 keys.");
t.strictSame(res, { VF6: 0 }, "Should return VF4 and VF5 keys.");
t.end();
});
t.end();
});
t.test("Should return VF4 and VF5 for USC", async (t) => {
const res = await CalculateRatings("usc", "Single", 1, logger);
t.test("Should return VF4 and VF5 for USC", async (t) => {
const res = await CalculateRatings("usc", "Single", 1, logger);
t.strictSame(res, { VF6: 0 }, "Should return VF4 and VF5 keys.");
t.strictSame(res, { VF6: 0 }, "Should return VF4 and VF5 keys.");
t.end();
});
t.end();
});
t.test("Should return MFCP for DDR", async (t) => {
const res = await CalculateRatings("ddr", "SP", 1, logger);
t.test("Should return MFCP for DDR", async (t) => {
const res = await CalculateRatings("ddr", "SP", 1, logger);
t.strictSame(res, { MFCP: 0, ktRating: 0 }, "Should return MFCP keys.");
t.strictSame(res, { MFCP: 0, ktRating: 0 }, "Should return MFCP keys.");
const resDP = await CalculateRatings("ddr", "DP", 1, logger);
const resDP = await CalculateRatings("ddr", "DP", 1, logger);
t.strictSame(resDP, { MFCP: 0, ktRating: 0 }, "Should return MFCP keys.");
t.strictSame(resDP, { MFCP: 0, ktRating: 0 }, "Should return MFCP keys.");
t.end();
});
t.end();
});
t.test("Should return skill for Gitadora", async (t) => {
const res = await CalculateRatings("gitadora", "Dora", 1, logger);
t.test("Should return skill for Gitadora", async (t) => {
const res = await CalculateRatings("gitadora", "Dora", 1, logger);
t.strictSame(res, { skill: 0 }, "Should return skill keys.");
t.strictSame(res, { skill: 0 }, "Should return skill keys.");
const resDP = await CalculateRatings("gitadora", "Gita", 1, logger);
const resDP = await CalculateRatings("gitadora", "Gita", 1, logger);
t.strictSame(resDP, { skill: 0 }, "Should return skill keys.");
t.strictSame(resDP, { skill: 0 }, "Should return skill keys.");
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -1,10 +1,10 @@
import {
Game,
Playtypes,
integer,
ScoreCalculatedDataLookup,
IDStrings,
UserGameStats,
Game,
Playtypes,
integer,
ScoreCalculatedDataLookup,
IDStrings,
UserGameStats,
} from "tachi-common";
import db from "../../../../external/mongo/db";
import { KtLogger } from "../../../logger/logger";
@@ -12,219 +12,219 @@ import { KtLogger } from "../../../logger/logger";
type CustomCalcNames = ScoreCalculatedDataLookup[IDStrings];
function LazySumAll(key: CustomCalcNames) {
return async (game: Game, playtype: Playtypes[Game], userID: integer) => {
const sc = await db["personal-bests"].find({
game: game,
playtype: playtype,
userID: userID,
isPrimary: true,
[`calculatedData.${key}`]: { $gt: 0 },
});
return async (game: Game, playtype: Playtypes[Game], userID: integer) => {
const sc = await db["personal-bests"].find({
game: game,
playtype: playtype,
userID: userID,
isPrimary: true,
[`calculatedData.${key}`]: { $gt: 0 },
});
const result = sc.reduce((a, b) => a + b.calculatedData[key]!, 0);
const result = sc.reduce((a, b) => a + b.calculatedData[key]!, 0);
return result;
};
return result;
};
}
function LazyCalcN(key: CustomCalcNames, n: integer, returnMean?: boolean) {
return async (game: Game, playtype: Playtypes[Game], userID: integer) => {
const sc = await db["personal-bests"].find(
{
game: game,
playtype: playtype,
userID: userID,
isPrimary: true,
[`calculatedData.${key}`]: { $gt: 0 },
},
{
limit: n,
sort: { [`calculatedData.${key}`]: -1 },
}
);
return async (game: Game, playtype: Playtypes[Game], userID: integer) => {
const sc = await db["personal-bests"].find(
{
game: game,
playtype: playtype,
userID: userID,
isPrimary: true,
[`calculatedData.${key}`]: { $gt: 0 },
},
{
limit: n,
sort: { [`calculatedData.${key}`]: -1 },
}
);
let result = sc.reduce((a, b) => a + b.calculatedData[key]!, 0);
let result = sc.reduce((a, b) => a + b.calculatedData[key]!, 0);
if (returnMean) {
result = result / n;
}
if (returnMean) {
result = result / n;
}
return result;
};
return result;
};
}
const LazySumN = (key: CustomCalcNames, n: integer) => LazyCalcN(key, n, false);
const LazyMeanN = (key: CustomCalcNames, n: integer) => LazyCalcN(key, n, true);
type RatingFunctions = {
[G in Game]: {
[P in Playtypes[G]]: (
game: Game,
playtype: Playtypes[Game],
userID: integer,
logger: KtLogger
) => Promise<UserGameStats["ratings"]>;
};
[G in Game]: {
[P in Playtypes[G]]: (
game: Game,
playtype: Playtypes[Game],
userID: integer,
logger: KtLogger
) => Promise<UserGameStats["ratings"]>;
};
};
const RatingFunctions: RatingFunctions = {
iidx: {
SP: async (g, p, u) => ({
BPI: await LazyMeanN("BPI", 20)(g, p, u),
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
ktLampRating: await LazyMeanN("ktLampRating", 20)(g, p, u),
}),
DP: async (g, p, u) => ({
BPI: await LazyMeanN("BPI", 20)(g, p, u),
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
ktLampRating: await LazyMeanN("ktLampRating", 20)(g, p, u),
}),
},
sdvx: {
Single: async (g, p, u) => ({
VF6: await LazySumN("VF6", 50)(g, p, u),
}),
},
usc: {
Single: async (g, p, u) => ({
VF6: await LazySumN("VF6", 50)(g, p, u),
}),
},
ddr: {
SP: async (g, p, u) => ({
MFCP: await LazySumAll("MFCP")(g, p, u),
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
}),
DP: async (g, p, u) => ({
MFCP: await LazySumAll("MFCP")(g, p, u),
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
}),
},
gitadora: {
Gita: async (g, p, u, l) => ({ skill: await CalculateGitadoraSkill(g, p, u, l) }),
Dora: async (g, p, u, l) => ({ skill: await CalculateGitadoraSkill(g, p, u, l) }),
},
bms: {
"7K": async (g, p, u) => ({
ktLampRating: await LazyMeanN("ktLampRating", 20)(g, p, u),
}),
"14K": async (g, p, u) => ({
ktLampRating: await LazyMeanN("ktLampRating", 20)(g, p, u),
}),
},
chunithm: {
Single: async (g, p, u) => ({
naiveRating: await LazyMeanN("rating", 20)(g, p, u),
}),
},
popn: {
"9B": async () => ({}),
},
museca: {
Single: async (g, p, u) => ({
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
}),
},
maimai: {
Single: async (g, p, u) => ({
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
}),
},
jubeat: {
Single: async (g, p, u, l) => ({
jubility: await CalculateJubility(g, p, u, l),
}),
},
iidx: {
SP: async (g, p, u) => ({
BPI: await LazyMeanN("BPI", 20)(g, p, u),
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
ktLampRating: await LazyMeanN("ktLampRating", 20)(g, p, u),
}),
DP: async (g, p, u) => ({
BPI: await LazyMeanN("BPI", 20)(g, p, u),
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
ktLampRating: await LazyMeanN("ktLampRating", 20)(g, p, u),
}),
},
sdvx: {
Single: async (g, p, u) => ({
VF6: await LazySumN("VF6", 50)(g, p, u),
}),
},
usc: {
Single: async (g, p, u) => ({
VF6: await LazySumN("VF6", 50)(g, p, u),
}),
},
ddr: {
SP: async (g, p, u) => ({
MFCP: await LazySumAll("MFCP")(g, p, u),
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
}),
DP: async (g, p, u) => ({
MFCP: await LazySumAll("MFCP")(g, p, u),
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
}),
},
gitadora: {
Gita: async (g, p, u, l) => ({ skill: await CalculateGitadoraSkill(g, p, u, l) }),
Dora: async (g, p, u, l) => ({ skill: await CalculateGitadoraSkill(g, p, u, l) }),
},
bms: {
"7K": async (g, p, u) => ({
ktLampRating: await LazyMeanN("ktLampRating", 20)(g, p, u),
}),
"14K": async (g, p, u) => ({
ktLampRating: await LazyMeanN("ktLampRating", 20)(g, p, u),
}),
},
chunithm: {
Single: async (g, p, u) => ({
naiveRating: await LazyMeanN("rating", 20)(g, p, u),
}),
},
popn: {
"9B": async () => ({}),
},
museca: {
Single: async (g, p, u) => ({
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
}),
},
maimai: {
Single: async (g, p, u) => ({
ktRating: await LazyMeanN("ktRating", 20)(g, p, u),
}),
},
jubeat: {
Single: async (g, p, u, l) => ({
jubility: await CalculateJubility(g, p, u, l),
}),
},
};
export function CalculateRatings(
game: Game,
playtype: Playtypes[Game],
userID: integer,
logger: KtLogger
game: Game,
playtype: Playtypes[Game],
userID: integer,
logger: KtLogger
): Promise<Partial<Record<CustomCalcNames, number>>> {
// @ts-expect-error too lazy to type this properly
return RatingFunctions[game][playtype](game, playtype, userID, logger);
// @ts-expect-error too lazy to type this properly
return RatingFunctions[game][playtype](game, playtype, userID, logger);
}
async function CalculateGitadoraSkill(
game: Game,
playtype: Playtypes[Game],
userID: integer,
logger: KtLogger
game: Game,
playtype: Playtypes[Game],
userID: integer,
logger: KtLogger
) {
const hotCharts = await db.charts.gitadora.find(
{ "flags.HOT N-1": true },
{ projection: { chartID: 1 } }
);
const hotCharts = await db.charts.gitadora.find(
{ "flags.HOT N-1": true },
{ projection: { chartID: 1 } }
);
const hotChartIDs = hotCharts.map((e) => e.chartID);
const hotChartIDs = hotCharts.map((e) => e.chartID);
const [bestHotScores, bestScores] = await Promise.all([
db["personal-bests"].find(
{ userID, chartID: { $in: hotChartIDs } },
{
sort: { "calculatedData.skill": -1 },
limit: 25,
projection: { "calculatedData.skill": 1 },
}
),
// @inefficient
// $nin is VERY expensive, there might be a better way to do this.
db["personal-bests"].find(
{ userID, chartID: { $nin: hotChartIDs } },
{
sort: { "calculatedData.skill": -1 },
limit: 25,
projection: { "calculatedData.skill": 1 },
}
),
]);
const [bestHotScores, bestScores] = await Promise.all([
db["personal-bests"].find(
{ userID, chartID: { $in: hotChartIDs } },
{
sort: { "calculatedData.skill": -1 },
limit: 25,
projection: { "calculatedData.skill": 1 },
}
),
// @inefficient
// $nin is VERY expensive, there might be a better way to do this.
db["personal-bests"].find(
{ userID, chartID: { $nin: hotChartIDs } },
{
sort: { "calculatedData.skill": -1 },
limit: 25,
projection: { "calculatedData.skill": 1 },
}
),
]);
let skill = 0;
skill += bestHotScores.reduce((a, r) => a + r.calculatedData.skill!, 0);
skill += bestScores.reduce((a, r) => a + r.calculatedData.skill!, 0);
let skill = 0;
skill += bestHotScores.reduce((a, r) => a + r.calculatedData.skill!, 0);
skill += bestScores.reduce((a, r) => a + r.calculatedData.skill!, 0);
return skill;
return skill;
}
async function CalculateJubility(
game: Game,
playtype: Playtypes[Game],
userID: integer,
logger: KtLogger
game: Game,
playtype: Playtypes[Game],
userID: integer,
logger: KtLogger
): Promise<number> {
const hotCharts = await db.charts.jubeat.find(
{ "flags.HOT N-1": true },
{ projection: { chartID: 1 } }
);
const hotCharts = await db.charts.jubeat.find(
{ "flags.HOT N-1": true },
{ projection: { chartID: 1 } }
);
const hotChartIDs = hotCharts.map((e) => e.chartID);
const hotChartIDs = hotCharts.map((e) => e.chartID);
const [bestHotScores, bestScores] = await Promise.all([
db["personal-bests"].find(
{ userID, chartID: { $in: hotChartIDs } },
{
sort: { "calculatedData.jubility": -1 },
limit: 25,
projection: { "calculatedData.jubility": 1 },
}
),
// @inefficient
// see gitadoraskillcalc
db["personal-bests"].find(
{ userID, chartID: { $nin: hotChartIDs } },
{
sort: { "calculatedData.jubility": -1 },
limit: 25,
projection: { "calculatedData.jubility": 1 },
}
),
]);
const [bestHotScores, bestScores] = await Promise.all([
db["personal-bests"].find(
{ userID, chartID: { $in: hotChartIDs } },
{
sort: { "calculatedData.jubility": -1 },
limit: 25,
projection: { "calculatedData.jubility": 1 },
}
),
// @inefficient
// see gitadoraskillcalc
db["personal-bests"].find(
{ userID, chartID: { $nin: hotChartIDs } },
{
sort: { "calculatedData.jubility": -1 },
limit: 25,
projection: { "calculatedData.jubility": 1 },
}
),
]);
let skill = 0;
skill += bestHotScores.reduce((a, r) => a + r.calculatedData.jubility!, 0);
skill += bestScores.reduce((a, r) => a + r.calculatedData.jubility!, 0);
let skill = 0;
skill += bestHotScores.reduce((a, r) => a + r.calculatedData.jubility!, 0);
skill += bestScores.reduce((a, r) => a + r.calculatedData.jubility!, 0);
return skill;
return skill;
}
@@ -6,11 +6,11 @@ import { KtLogger } from "../../../logger/logger";
export type ScoreClasses = Partial<GameClasses<IDStrings>>;
export interface ClassHandler {
(
game: Game,
playtype: Playtypes[Game],
userID: integer,
ratings: Record<string, number>,
logger: KtLogger
): Promise<ScoreClasses> | ScoreClasses | undefined;
(
game: Game,
playtype: Playtypes[Game],
userID: integer,
ratings: Record<string, number>,
logger: KtLogger
): Promise<ScoreClasses> | ScoreClasses | undefined;
}
@@ -12,185 +12,185 @@ const logger = CreateLogCtx(__filename);
// more of an integration test
t.test("#UpdateUsersGamePlaytypeStats", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should create new UserGameStats if the user has none", async (t) => {
await db["game-stats"].remove({});
t.test("Should create new UserGameStats if the user has none", async (t) => {
await db["game-stats"].remove({});
const res = await UpdateUsersGamePlaytypeStats("iidx", "SP", 1, null, logger);
const res = await UpdateUsersGamePlaytypeStats("iidx", "SP", 1, null, logger);
t.strictSame(res, [], "Should return an empty object");
t.strictSame(res, [], "Should return an empty object");
const gs = await db["game-stats"].findOne();
const gs = await db["game-stats"].findOne();
t.hasStrict(
gs,
{
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {},
},
"Should insert an appropriate game-stats object"
);
t.hasStrict(
gs,
{
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {},
},
"Should insert an appropriate game-stats object"
);
t.end();
});
t.end();
});
t.test("Should update UserGameStats if the user has one", async (t) => {
await db["game-stats"].remove({});
t.test("Should update UserGameStats if the user has one", async (t) => {
await db["game-stats"].remove({});
await db["game-stats"].insert({
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {},
});
await db["game-stats"].insert({
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {},
});
// insert some mock scores
const ratings = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// insert some mock scores
const ratings = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
await db["personal-bests"].insert(
ratings.map((e) =>
deepmerge(TestingIIDXSPScorePB, {
chartID: crypto.randomBytes(20).toString("hex"),
calculatedData: {
ktRating: e,
ktLampRating: 0,
},
})
)
);
await db["personal-bests"].insert(
ratings.map((e) =>
deepmerge(TestingIIDXSPScorePB, {
chartID: crypto.randomBytes(20).toString("hex"),
calculatedData: {
ktRating: e,
ktLampRating: 0,
},
})
)
);
const res = await UpdateUsersGamePlaytypeStats("iidx", "SP", 1, null, logger);
const res = await UpdateUsersGamePlaytypeStats("iidx", "SP", 1, null, logger);
t.strictSame(res, [], "Should return an empty object");
t.strictSame(res, [], "Should return an empty object");
const gs = await db["game-stats"].findOne();
const gs = await db["game-stats"].findOne();
t.hasStrict(
gs,
{
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: ratings.reduce((a, r) => a + r, 0) / 20, ktLampRating: 0 },
classes: {},
},
"Should update the game-stats object"
);
t.hasStrict(
gs,
{
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: ratings.reduce((a, r) => a + r, 0) / 20, ktLampRating: 0 },
classes: {},
},
"Should update the game-stats object"
);
t.end();
});
t.end();
});
t.test("Should return class deltas", async (t) => {
await db["game-stats"].remove({});
t.test("Should return class deltas", async (t) => {
await db["game-stats"].remove({});
await db["game-stats"].insert({
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
await db["game-stats"].insert({
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {},
});
classes: {},
});
const res = await UpdateUsersGamePlaytypeStats(
"iidx",
"SP",
1,
() => ({ dan: 18 }), // lmao
logger
);
const res = await UpdateUsersGamePlaytypeStats(
"iidx",
"SP",
1,
() => ({ dan: 18 }), // lmao
logger
);
t.strictSame(
res,
[
{
set: "dan",
playtype: "SP",
old: null,
new: 18,
},
],
"Should return the class delta"
);
t.strictSame(
res,
[
{
set: "dan",
playtype: "SP",
old: null,
new: 18,
},
],
"Should return the class delta"
);
const gs = await db["game-stats"].findOne();
const gs = await db["game-stats"].findOne();
t.hasStrict(
gs,
{
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {
dan: 18,
},
},
"Should update the game-stats object"
);
t.hasStrict(
gs,
{
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {
dan: 18,
},
},
"Should update the game-stats object"
);
t.end();
});
t.end();
});
t.test("Should return updated class deltas", async (t) => {
await db["game-stats"].remove({});
t.test("Should return updated class deltas", async (t) => {
await db["game-stats"].remove({});
await db["game-stats"].insert({
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {
dan: 17,
},
});
await db["game-stats"].insert({
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {
dan: 17,
},
});
const res = await UpdateUsersGamePlaytypeStats(
"iidx",
"SP",
1,
() => ({ dan: 18 }), // lmao
logger
);
const res = await UpdateUsersGamePlaytypeStats(
"iidx",
"SP",
1,
() => ({ dan: 18 }), // lmao
logger
);
t.strictSame(
res,
[
{
set: "dan",
playtype: "SP",
old: 17,
new: 18,
},
],
"Should return the updated class delta"
);
t.strictSame(
res,
[
{
set: "dan",
playtype: "SP",
old: 17,
new: 18,
},
],
"Should return the updated class delta"
);
const gs = await db["game-stats"].findOne();
const gs = await db["game-stats"].findOne();
t.hasStrict(
gs,
{
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {
dan: 18,
},
},
"Should update the game-stats object"
);
t.hasStrict(
gs,
{
game: "iidx",
playtype: "SP",
userID: 1,
ratings: { ktRating: 0, ktLampRating: 0 },
classes: {
dan: 18,
},
},
"Should update the game-stats object"
);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -10,61 +10,61 @@ import { CalculateRatings } from "./rating";
import { ClassHandler } from "./types";
export async function UpdateUsersGamePlaytypeStats(
game: Game,
playtype: Playtypes[Game],
userID: integer,
classHandler: ClassHandler | null,
logger: KtLogger
game: Game,
playtype: Playtypes[Game],
userID: integer,
classHandler: ClassHandler | null,
logger: KtLogger
): Promise<ClassDelta[]> {
const ratings = await CalculateRatings(game, playtype, userID, logger);
const ratings = await CalculateRatings(game, playtype, userID, logger);
// 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!
const userGameStats = await db["game-stats"].findOne({
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!
const userGameStats = await db["game-stats"].findOne({
game,
playtype,
userID,
});
logger.debug(`Calculating UGSClasses...`);
logger.debug(`Calculating UGSClasses...`);
const classes = await UpdateUGSClasses(game, playtype, userID, ratings, classHandler, logger);
const classes = await UpdateUGSClasses(game, playtype, userID, ratings, classHandler, logger);
logger.debug(`Finished Calculating UGSClasses`);
logger.debug(`Finished Calculating UGSClasses`);
logger.debug(`Calculating Class Deltas...`);
logger.debug(`Calculating Class Deltas...`);
const deltas = CalculateClassDeltas(playtype, classes, userGameStats, userID, logger);
const deltas = CalculateClassDeltas(playtype, classes, userGameStats, userID, logger);
logger.debug(`Had ${deltas.length} deltas.`);
logger.debug(`Had ${deltas.length} deltas.`);
if (userGameStats) {
logger.debug(`Updated player gamestats for ${game} (${playtype})`);
await db["game-stats"].update(
{
game,
playtype,
userID,
},
{
$set: {
ratings,
classes,
},
}
);
} else {
const newStats: UserGameStats = {
game,
playtype,
userID,
ratings,
classes,
};
if (userGameStats) {
logger.debug(`Updated player gamestats for ${game} (${playtype})`);
await db["game-stats"].update(
{
game,
playtype,
userID,
},
{
$set: {
ratings,
classes,
},
}
);
} else {
const newStats: UserGameStats = {
game,
playtype,
userID,
ratings,
classes,
};
logger.info(`Created new gamestats for ${game} (${playtype})`);
await db["game-stats"].insert(newStats);
}
logger.info(`Created new gamestats for ${game} (${playtype})`);
await db["game-stats"].insert(newStats);
}
return deltas;
return deltas;
}
@@ -14,96 +14,96 @@ const putySong = GetKTDataJSON("./tachi/ddr-puty-song.json") as ARCDDRScore;
const putyChart = GetKTDataJSON("./tachi/ddr-puty-chart.json") as ARCDDRScore;
t.test("#ConvertAPIArcDDR", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should convert a valid score", async (t) => {
const res = await ConvertAPIArcDDR(arcScore, {}, "api/arc-ddr", logger);
t.test("Should convert a valid score", async (t) => {
const res = await ConvertAPIArcDDR(arcScore, {}, "api/arc-ddr", logger);
t.hasStrict(res, {
song: putySong,
chart: putyChart,
dryScore: {
comment: null,
game: "ddr",
importType: "api/arc-ddr",
timeAchieved: 1591822550346,
service: "ARC DDR Ace",
scoreData: {
grade: "AAA",
percent: 99.976,
score: 807,
hitData: {
marvelous: 236,
perfect: 24,
great: 0,
good: 0,
boo: 0,
miss: 0,
ok: 17,
ng: 0,
},
hitMeta: {
fast: 10,
slow: 14,
maxCombo: 260,
exScore: 807,
},
lamp: "PERFECT FULL COMBO",
},
scoreMeta: {},
},
});
t.hasStrict(res, {
song: putySong,
chart: putyChart,
dryScore: {
comment: null,
game: "ddr",
importType: "api/arc-ddr",
timeAchieved: 1591822550346,
service: "ARC DDR Ace",
scoreData: {
grade: "AAA",
percent: 99.976,
score: 807,
hitData: {
marvelous: 236,
perfect: 24,
great: 0,
good: 0,
boo: 0,
miss: 0,
ok: 17,
ng: 0,
},
hitMeta: {
fast: 10,
slow: 14,
maxCombo: 260,
exScore: 807,
},
lamp: "PERFECT FULL COMBO",
},
scoreMeta: {},
},
});
t.end();
});
t.end();
});
t.test("Should throw on an invalid score", (t) => {
t.rejects(
() =>
ConvertAPIArcDDR(
deepmerge(arcScore, { ex_score: "foo" }),
{},
"api/arc-ddr",
logger
),
{
message: /Invalid ARC Score:/iu,
} as any
);
t.test("Should throw on an invalid score", (t) => {
t.rejects(
() =>
ConvertAPIArcDDR(
deepmerge(arcScore, { ex_score: "foo" }),
{},
"api/arc-ddr",
logger
),
{
message: /Invalid ARC Score:/iu,
} as any
);
t.end();
});
t.end();
});
t.test("Should throw on no chart", (t) => {
t.rejects(
() =>
ConvertAPIArcDDR(
deepmerge(arcScore, { chart_id: "foo" }),
{},
"api/arc-ddr",
logger
),
{
message: /Could not find chart/iu,
} as any
);
t.test("Should throw on no chart", (t) => {
t.rejects(
() =>
ConvertAPIArcDDR(
deepmerge(arcScore, { chart_id: "foo" }),
{},
"api/arc-ddr",
logger
),
{
message: /Could not find chart/iu,
} as any
);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.test("#ResolveARCDDRLamp", (t) => {
t.equal(ResolveARCDDRLamp("FAIL"), "FAILED");
t.equal(ResolveARCDDRLamp("CLEAR"), "CLEAR");
t.equal(ResolveARCDDRLamp("CLEAR_3LIFE"), "LIFE4");
t.equal(ResolveARCDDRLamp("GOOD_FC"), "FULL COMBO");
t.equal(ResolveARCDDRLamp("GREAT_FC"), "GREAT FULL COMBO");
t.equal(ResolveARCDDRLamp("PERFECT_FC"), "PERFECT FULL COMBO");
t.equal(ResolveARCDDRLamp("MARVELOUS_FC"), "MARVELOUS FULL COMBO");
t.equal(ResolveARCDDRLamp("FAIL"), "FAILED");
t.equal(ResolveARCDDRLamp("CLEAR"), "CLEAR");
t.equal(ResolveARCDDRLamp("CLEAR_3LIFE"), "LIFE4");
t.equal(ResolveARCDDRLamp("GOOD_FC"), "FULL COMBO");
t.equal(ResolveARCDDRLamp("GREAT_FC"), "GREAT FULL COMBO");
t.equal(ResolveARCDDRLamp("PERFECT_FC"), "PERFECT FULL COMBO");
t.equal(ResolveARCDDRLamp("MARVELOUS_FC"), "MARVELOUS FULL COMBO");
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -2,8 +2,8 @@ import { EmptyObject } from "../../../../../utils/types";
import { ConverterFunction } from "../../common/types";
import p, { PrudenceSchema } from "prudence";
import {
InvalidScoreFailure,
KTDataNotFoundFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "../../../framework/common/converter-failures";
import { FormatPrError } from "../../../../../utils/prudence";
import { ARCDDRScore } from "./types";
@@ -11,128 +11,128 @@ import { FindChartOnARCID } from "../../../../../utils/queries/charts";
import { FindSongOnIDGuaranteed } from "../../../../../utils/queries/songs";
import { DryScore } from "../../../framework/common/types";
import {
GenericGetGradeAndPercent,
ParseDateFromString,
GenericGetGradeAndPercent,
ParseDateFromString,
} from "../../../framework/common/score-utils";
import { Lamps } from "tachi-common";
// There's a bunch of other useless fields but we don't care
const PR_ArcDDRScore: PrudenceSchema = {
chart_id: "string",
lamp: p.isIn(
"MARVELOUS_FC",
"PERFECT_FC",
"GREAT_FC",
"GOOD_FC",
"CLEAR_3LIFE",
"CLEAR",
"FAIL"
),
score: p.isBoundedInteger(0, 1_000_000),
ex_score: p.isPositiveInteger,
max_combo: p.isPositiveInteger,
judgments: {
marvelous: p.isPositiveInteger,
perfect: p.isPositiveInteger,
great: p.isPositiveInteger,
good: p.isPositiveInteger,
boo: p.isPositiveInteger,
miss: p.isPositiveInteger,
ok: p.isPositiveInteger,
ng: p.isPositiveInteger,
},
fast: p.isPositiveInteger,
slow: p.isPositiveInteger,
timestamp: "string",
chart_id: "string",
lamp: p.isIn(
"MARVELOUS_FC",
"PERFECT_FC",
"GREAT_FC",
"GOOD_FC",
"CLEAR_3LIFE",
"CLEAR",
"FAIL"
),
score: p.isBoundedInteger(0, 1_000_000),
ex_score: p.isPositiveInteger,
max_combo: p.isPositiveInteger,
judgments: {
marvelous: p.isPositiveInteger,
perfect: p.isPositiveInteger,
great: p.isPositiveInteger,
good: p.isPositiveInteger,
boo: p.isPositiveInteger,
miss: p.isPositiveInteger,
ok: p.isPositiveInteger,
ng: p.isPositiveInteger,
},
fast: p.isPositiveInteger,
slow: p.isPositiveInteger,
timestamp: "string",
};
export const ConvertAPIArcDDR: ConverterFunction<unknown, EmptyObject> = async (
data,
context,
importType,
logger
data,
context,
importType,
logger
) => {
const err = p(data, PR_ArcDDRScore, {}, { throwOnNonObject: false, allowExcessKeys: true });
const err = p(data, PR_ArcDDRScore, {}, { throwOnNonObject: false, allowExcessKeys: true });
if (err) {
throw new InvalidScoreFailure(FormatPrError(err, "Invalid ARC Score: "));
}
if (err) {
throw new InvalidScoreFailure(FormatPrError(err, "Invalid ARC Score: "));
}
// confirmed by Prudence above.
const score = data as ARCDDRScore;
// confirmed by Prudence above.
const score = data as ARCDDRScore;
const chart = await FindChartOnARCID("ddr", score.chart_id);
const chart = await FindChartOnARCID("ddr", score.chart_id);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with chart_id ${score.chart_id}.`,
importType,
data,
context
);
}
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with chart_id ${score.chart_id}.`,
importType,
data,
context
);
}
const song = await FindSongOnIDGuaranteed("ddr", chart.songID, logger);
const song = await FindSongOnIDGuaranteed("ddr", chart.songID, logger);
const { grade, percent } = GenericGetGradeAndPercent("ddr", score.score, chart);
const { grade, percent } = GenericGetGradeAndPercent("ddr", score.score, chart);
const timeAchieved = ParseDateFromString(score.timestamp);
const timeAchieved = ParseDateFromString(score.timestamp);
const lamp = ResolveARCDDRLamp(score.lamp);
const lamp = ResolveARCDDRLamp(score.lamp);
const dryScore: DryScore<"ddr:SP" | "ddr:DP"> = {
comment: null,
game: "ddr",
importType,
timeAchieved,
service: "ARC DDR Ace",
scoreData: {
grade,
percent,
score: score.ex_score,
hitData: {
marvelous: score.judgments.marvelous,
perfect: score.judgments.perfect,
great: score.judgments.great,
good: score.judgments.good,
boo: score.judgments.boo,
miss: score.judgments.miss,
ok: score.judgments.ok,
ng: score.judgments.ng,
},
hitMeta: {
fast: score.fast,
slow: score.slow,
maxCombo: score.max_combo,
exScore: score.ex_score,
},
lamp,
},
scoreMeta: {},
};
const dryScore: DryScore<"ddr:SP" | "ddr:DP"> = {
comment: null,
game: "ddr",
importType,
timeAchieved,
service: "ARC DDR Ace",
scoreData: {
grade,
percent,
score: score.ex_score,
hitData: {
marvelous: score.judgments.marvelous,
perfect: score.judgments.perfect,
great: score.judgments.great,
good: score.judgments.good,
boo: score.judgments.boo,
miss: score.judgments.miss,
ok: score.judgments.ok,
ng: score.judgments.ng,
},
hitMeta: {
fast: score.fast,
slow: score.slow,
maxCombo: score.max_combo,
exScore: score.ex_score,
},
lamp,
},
scoreMeta: {},
};
return { song, chart, dryScore };
return { song, chart, dryScore };
};
export function ResolveARCDDRLamp(lamp: ARCDDRScore["lamp"]): Lamps["ddr:SP" | "ddr:DP"] {
switch (lamp) {
case "FAIL":
return "FAILED";
case "CLEAR":
return "CLEAR";
case "CLEAR_3LIFE":
return "LIFE4";
case "GOOD_FC":
return "FULL COMBO";
case "GREAT_FC":
return "GREAT FULL COMBO";
case "PERFECT_FC":
return "PERFECT FULL COMBO";
case "MARVELOUS_FC":
return "MARVELOUS FULL COMBO";
}
switch (lamp) {
case "FAIL":
return "FAILED";
case "CLEAR":
return "CLEAR";
case "CLEAR_3LIFE":
return "LIFE4";
case "GOOD_FC":
return "FULL COMBO";
case "GREAT_FC":
return "GREAT FULL COMBO";
case "PERFECT_FC":
return "PERFECT FULL COMBO";
case "MARVELOUS_FC":
return "MARVELOUS FULL COMBO";
}
// failsafe
/* istanbul ignore next */
throw new InvalidScoreFailure(`Invalid lamp ${lamp} - Could not resolve.`);
// failsafe
/* istanbul ignore next */
throw new InvalidScoreFailure(`Invalid lamp ${lamp} - Could not resolve.`);
}
@@ -9,38 +9,38 @@ import { ParseArcDDR } from "./parser";
const logger = CreateLogCtx(__filename);
t.test("#ParseArcDDR", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should iterate over the API.", async (t) => {
const mockArcAPI = MockJSONFetch({
"https://arc.example.com/api/v1/ddr/16/player_bests?profile_id=profile": {
_links: {
_next: "https://arc.example.com/api/v1/ddr/16/player_bests?profile_id=profile&page=2",
},
_items: [1, 2, 3],
},
"https://arc.example.com/api/v1/ddr/16/player_bests?profile_id=profile&page=2": {
_links: {
_next: null,
},
_items: [4, 5, 6],
},
});
t.test("Should iterate over the API.", async (t) => {
const mockArcAPI = MockJSONFetch({
"https://arc.example.com/api/v1/ddr/16/player_bests?profile_id=profile": {
_links: {
_next: "https://arc.example.com/api/v1/ddr/16/player_bests?profile_id=profile&page=2",
},
_items: [1, 2, 3],
},
"https://arc.example.com/api/v1/ddr/16/player_bests?profile_id=profile&page=2": {
_links: {
_next: null,
},
_items: [4, 5, 6],
},
});
const res = ParseArcDDR("profile", logger, mockArcAPI);
const res = ParseArcDDR("profile", logger, mockArcAPI);
t.equal(res.game, "ddr");
t.strictSame(res.context, {});
t.equal(res.classHandler, null);
t.equal(res.game, "ddr");
t.strictSame(res.context, {});
t.equal(res.classHandler, null);
const iter = await agta(res.iterable);
const iter = await agta(res.iterable);
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -6,21 +6,21 @@ import { EmptyObject } from "../../../../../utils/types";
import { ARC_API_URL, ARC_AUTH_TOKEN } from "../../../../setup/config";
export function ParseArcDDR(
arcProfileID: string,
logger: KtLogger,
fetch = nodeFetch
arcProfileID: string,
logger: KtLogger,
fetch = nodeFetch
): ParserFunctionReturns<unknown, EmptyObject> {
return {
iterable: TraverseKaiAPI(
ARC_API_URL,
// DDR Ace.
`/api/v1/ddr/16/player_bests?profile_id=${arcProfileID}`,
ARC_AUTH_TOKEN,
logger,
fetch
),
context: {},
classHandler: null,
game: "ddr",
};
return {
iterable: TraverseKaiAPI(
ARC_API_URL,
// DDR Ace.
`/api/v1/ddr/16/player_bests?profile_id=${arcProfileID}`,
ARC_AUTH_TOKEN,
logger,
fetch
),
context: {},
classHandler: null,
game: "ddr",
};
}
@@ -1,22 +1,22 @@
import { integer } from "tachi-common";
export interface ARCDDRScore {
chart_id: string;
lamp: "MARVELOUS_FC" | "PERFECT_FC" | "GREAT_FC" | "GOOD_FC" | "CLEAR_3LIFE" | "CLEAR" | "FAIL";
score: integer;
ex_score: integer;
max_combo: integer;
judgments: {
marvelous: integer;
perfect: integer;
great: integer;
good: integer;
boo: integer;
miss: integer;
ok: integer;
ng: integer;
};
fast: integer;
slow: integer;
timestamp: string;
chart_id: string;
lamp: "MARVELOUS_FC" | "PERFECT_FC" | "GREAT_FC" | "GOOD_FC" | "CLEAR_3LIFE" | "CLEAR" | "FAIL";
score: integer;
ex_score: integer;
max_combo: integer;
judgments: {
marvelous: integer;
perfect: integer;
great: integer;
good: integer;
boo: integer;
miss: integer;
ok: integer;
ng: integer;
};
fast: integer;
slow: integer;
timestamp: string;
}
@@ -9,139 +9,139 @@ import { CreateArcIIDXClassHandler } from "./class-handler";
const logger = CreateLogCtx(__filename);
t.test("#CreateArcIIDXClassHandler", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should return a working ClassHandler.", async (t) => {
const mockFetch = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/profiles?_id=profile": {
_items: [
{
sp: {
rank: "中伝",
},
dp: {
rank: "ニ段",
},
},
],
},
});
t.test("Should return a working ClassHandler.", async (t) => {
const mockFetch = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/profiles?_id=profile": {
_items: [
{
sp: {
rank: "中伝",
},
dp: {
rank: "ニ段",
},
},
],
},
});
const fn = await CreateArcIIDXClassHandler("profile", "token", mockFetch);
const fn = await CreateArcIIDXClassHandler("profile", "token", mockFetch);
t.equal(fn.length, 5);
t.equal(fn.length, 5);
// its not async but typescript complains
const res = await fn("iidx", "SP", 1, {}, logger);
// its not async but typescript complains
const res = await fn("iidx", "SP", 1, {}, logger);
t.equal(res!.dan, IIDXClasses.CHUUDEN);
t.equal(res!.dan, IIDXClasses.CHUUDEN);
const res2 = await fn("iidx", "DP", 1, {}, logger);
t.equal(res2!.dan, IIDXClasses.DAN_2);
const res2 = await fn("iidx", "DP", 1, {}, logger);
t.equal(res2!.dan, IIDXClasses.DAN_2);
t.end();
});
t.end();
});
t.test("Should handle null.", async (t) => {
const mockFetch = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/profiles?_id=profile": {
_items: [
{
sp: {
rank: null,
},
dp: {
rank: null,
},
},
],
},
});
t.test("Should handle null.", async (t) => {
const mockFetch = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/profiles?_id=profile": {
_items: [
{
sp: {
rank: null,
},
dp: {
rank: null,
},
},
],
},
});
const fn = await CreateArcIIDXClassHandler("profile", "token", mockFetch);
const fn = await CreateArcIIDXClassHandler("profile", "token", mockFetch);
t.equal(fn.length, 5);
t.equal(fn.length, 5);
const res = await fn("iidx", "SP", 1, {}, logger);
const res = await fn("iidx", "SP", 1, {}, logger);
t.strictSame(res, {});
t.strictSame(res, {});
const res2 = await fn("iidx", "DP", 1, {}, logger);
t.strictSame(res2, {});
const res2 = await fn("iidx", "DP", 1, {}, logger);
t.strictSame(res2, {});
t.end();
});
t.end();
});
t.test("Should handle undefined.", async (t) => {
const mockFetch = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/profiles?_id=profile": {
_items: [{}],
},
});
t.test("Should handle undefined.", async (t) => {
const mockFetch = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/profiles?_id=profile": {
_items: [{}],
},
});
const fn = await CreateArcIIDXClassHandler("profile", "token", mockFetch);
const fn = await CreateArcIIDXClassHandler("profile", "token", mockFetch);
t.equal(fn.length, 5);
t.equal(fn.length, 5);
const res = await fn("iidx", "SP", 1, {}, logger);
const res = await fn("iidx", "SP", 1, {}, logger);
t.strictSame(res, {});
t.strictSame(res, {});
const res2 = await fn("iidx", "DP", 1, {}, logger);
t.strictSame(res2, {});
const res2 = await fn("iidx", "DP", 1, {}, logger);
t.strictSame(res2, {});
t.end();
});
t.end();
});
t.test("Should handle invalid classes.", async (t) => {
const mockFetch = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/profiles?_id=profile": {
_items: [
{
sp: {
rank: "invalid",
},
},
],
},
});
t.test("Should handle invalid classes.", async (t) => {
const mockFetch = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/profiles?_id=profile": {
_items: [
{
sp: {
rank: "invalid",
},
},
],
},
});
const fn = await CreateArcIIDXClassHandler("profile", "token", mockFetch);
const fn = await CreateArcIIDXClassHandler("profile", "token", mockFetch);
t.equal(fn.length, 5);
t.equal(fn.length, 5);
const res = await fn("iidx", "SP", 1, {}, logger);
const res = await fn("iidx", "SP", 1, {}, logger);
t.strictSame(res, {});
t.strictSame(res, {});
t.end();
});
t.end();
});
t.test("Should trigger failsafe if invalid playtype is used.", async (t) => {
const mockFetch = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/profiles?_id=profile": {
_items: [
{
sp: {
rank: "中伝",
},
},
],
},
});
t.test("Should trigger failsafe if invalid playtype is used.", async (t) => {
const mockFetch = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/profiles?_id=profile": {
_items: [
{
sp: {
rank: "中伝",
},
},
],
},
});
const fn = await CreateArcIIDXClassHandler("profile", "token", mockFetch);
const fn = await CreateArcIIDXClassHandler("profile", "token", mockFetch);
t.equal(fn.length, 5);
t.equal(fn.length, 5);
const res = await fn("iidx", "14K", 1, {}, logger);
const res = await fn("iidx", "14K", 1, {}, logger);
t.strictSame(res, {});
t.strictSame(res, {});
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -5,86 +5,86 @@ import { ARC_API_URL } from "../../../../setup/config";
import { ClassHandler } from "../../../framework/user-game-stats/types";
export async function CreateArcIIDXClassHandler(
profileID: string,
token: string,
fetch = nodeFetch
profileID: string,
token: string,
fetch = nodeFetch
): Promise<ClassHandler> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let json: any;
let err: Error | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let json: any;
let err: Error | undefined;
// SP and DP dans are located in the same place,
// fetch once, then return a function that traverses this data.
try {
const res = await fetch(`${ARC_API_URL}/api/v1/iidx/27/profiles?_id=${profileID}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
// SP and DP dans are located in the same place,
// fetch once, then return a function that traverses this data.
try {
const res = await fetch(`${ARC_API_URL}/api/v1/iidx/27/profiles?_id=${profileID}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
json = await res.json();
} catch (e) {
err = e;
}
json = await res.json();
} catch (e) {
err = e;
}
return (game, playtype, userID, ratings, logger) => {
/* istanbul ignore next */
if (err) {
logger.error(`An error occured while updating classes for ${ARC_API_URL}.`, { err });
return {};
}
return (game, playtype, userID, ratings, logger) => {
/* istanbul ignore next */
if (err) {
logger.error(`An error occured while updating classes for ${ARC_API_URL}.`, { err });
return {};
}
// we're just going to lazily path directly towards the rank.
// if ARC sends us an unexpected JSON structure or whatever
// we wont crash.
let arcClass: string | undefined | null;
// we're just going to lazily path directly towards the rank.
// if ARC sends us an unexpected JSON structure or whatever
// we wont crash.
let arcClass: string | undefined | null;
if (playtype === "SP") {
arcClass = json?._items?.[0]?.sp?.rank;
} else if (playtype === "DP") {
arcClass = json?._items?.[0]?.dp?.rank;
} else {
logger.error(`ARCIIDXClassUpdater called with invalid playtype of ${playtype}.`);
return {};
}
if (playtype === "SP") {
arcClass = json?._items?.[0]?.sp?.rank;
} else if (playtype === "DP") {
arcClass = json?._items?.[0]?.dp?.rank;
} else {
logger.error(`ARCIIDXClassUpdater called with invalid playtype of ${playtype}.`);
return {};
}
if (arcClass === null) {
return {};
}
if (arcClass === null) {
return {};
}
// arc's classes sometimes have trailing/leading spaces.
const trimmedArcClass = (arcClass ?? "").trim();
// arc's classes sometimes have trailing/leading spaces.
const trimmedArcClass = (arcClass ?? "").trim();
if (!HasOwnProperty(ARCClasses, trimmedArcClass)) {
logger.warn(`Invalid dan sent from ARC ${trimmedArcClass}. Ignoring.`);
return {};
}
if (!HasOwnProperty(ARCClasses, trimmedArcClass)) {
logger.warn(`Invalid dan sent from ARC ${trimmedArcClass}. Ignoring.`);
return {};
}
return {
dan: ARCClasses[trimmedArcClass],
};
};
return {
dan: ARCClasses[trimmedArcClass],
};
};
}
const ARCClasses = {
皆伝: IIDXClasses.KAIDEN,
中伝: IIDXClasses.CHUUDEN,
十段: IIDXClasses.DAN_10,
九段: IIDXClasses.DAN_9,
八段: IIDXClasses.DAN_8,
七段: IIDXClasses.DAN_7,
六段: IIDXClasses.DAN_6,
五段: IIDXClasses.DAN_5,
四段: IIDXClasses.DAN_4,
三段: IIDXClasses.DAN_3,
二段: IIDXClasses.DAN_2, // These two look very similar but they aren't
ニ段: IIDXClasses.DAN_2, // and ARC uses both, from what I can tell.
初段: IIDXClasses.DAN_1,
一級: IIDXClasses.KYU_1,
二級: IIDXClasses.KYU_2,
三級: IIDXClasses.KYU_3,
四級: IIDXClasses.KYU_4,
五級: IIDXClasses.KYU_5,
六級: IIDXClasses.KYU_6,
七級: IIDXClasses.KYU_7,
皆伝: IIDXClasses.KAIDEN,
中伝: IIDXClasses.CHUUDEN,
十段: IIDXClasses.DAN_10,
九段: IIDXClasses.DAN_9,
八段: IIDXClasses.DAN_8,
七段: IIDXClasses.DAN_7,
六段: IIDXClasses.DAN_6,
五段: IIDXClasses.DAN_5,
四段: IIDXClasses.DAN_4,
三段: IIDXClasses.DAN_3,
二段: IIDXClasses.DAN_2, // These two look very similar but they aren't
ニ段: IIDXClasses.DAN_2, // and ARC uses both, from what I can tell.
初段: IIDXClasses.DAN_1,
一級: IIDXClasses.KYU_1,
二級: IIDXClasses.KYU_2,
三級: IIDXClasses.KYU_3,
四級: IIDXClasses.KYU_4,
五級: IIDXClasses.KYU_5,
六級: IIDXClasses.KYU_6,
七級: IIDXClasses.KYU_7,
};
@@ -12,84 +12,84 @@ const logger = CreateLogCtx(__filename);
const arcScore = GetKTDataJSON("./api-arc/iidx-score.json") as ARCIIDXScore;
t.test("#ConvertAPIArcIIDX", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should convert a valid score", async (t) => {
const res = await ConvertAPIArcIIDX(arcScore, {}, "api/arc-iidx", logger);
t.test("Should convert a valid score", async (t) => {
const res = await ConvertAPIArcIIDX(arcScore, {}, "api/arc-iidx", logger);
t.hasStrict(res, {
song: Testing511Song,
chart: Testing511SPA,
dryScore: {
comment: null,
game: "iidx",
importType: "api/arc-iidx",
timeAchieved: 1604784681894,
service: "ARC IIDX27",
scoreData: {
grade: "A",
score: 1200,
hitData: {},
hitMeta: {
bp: 43,
},
lamp: "HARD CLEAR",
},
scoreMeta: {},
},
});
t.hasStrict(res, {
song: Testing511Song,
chart: Testing511SPA,
dryScore: {
comment: null,
game: "iidx",
importType: "api/arc-iidx",
timeAchieved: 1604784681894,
service: "ARC IIDX27",
scoreData: {
grade: "A",
score: 1200,
hitData: {},
hitMeta: {
bp: 43,
},
lamp: "HARD CLEAR",
},
scoreMeta: {},
},
});
t.end();
});
t.end();
});
t.test("Should throw on an invalid score", (t) => {
t.rejects(
() =>
ConvertAPIArcIIDX(
deepmerge(arcScore, { ex_score: "foo" }),
{},
"api/arc-iidx",
logger
),
{
message: /Invalid ARC Score:/iu,
} as any
);
t.test("Should throw on an invalid score", (t) => {
t.rejects(
() =>
ConvertAPIArcIIDX(
deepmerge(arcScore, { ex_score: "foo" }),
{},
"api/arc-iidx",
logger
),
{
message: /Invalid ARC Score:/iu,
} as any
);
t.end();
});
t.end();
});
t.test("Should throw on no chart", (t) => {
t.rejects(
() =>
ConvertAPIArcIIDX(
deepmerge(arcScore, { chart_id: "foo" }),
{},
"api/arc-iidx",
logger
),
{
message: /Could not find chart/iu,
} as any
);
t.test("Should throw on no chart", (t) => {
t.rejects(
() =>
ConvertAPIArcIIDX(
deepmerge(arcScore, { chart_id: "foo" }),
{},
"api/arc-iidx",
logger
),
{
message: /Could not find chart/iu,
} as any
);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.test("#ResolveARCIIDXLamp", (t) => {
t.equal(ResolveARCIIDXLamp("NO_PLAY"), "NO PLAY");
t.equal(ResolveARCIIDXLamp("FAILED"), "FAILED");
t.equal(ResolveARCIIDXLamp("ASSIST_CLEAR"), "ASSIST CLEAR");
t.equal(ResolveARCIIDXLamp("EASY_CLEAR"), "EASY CLEAR");
t.equal(ResolveARCIIDXLamp("CLEAR"), "CLEAR");
t.equal(ResolveARCIIDXLamp("HARD_CLEAR"), "HARD CLEAR");
t.equal(ResolveARCIIDXLamp("EX_HARD_CLEAR"), "EX HARD CLEAR");
t.equal(ResolveARCIIDXLamp("FULL_COMBO"), "FULL COMBO");
t.equal(ResolveARCIIDXLamp("NO_PLAY"), "NO PLAY");
t.equal(ResolveARCIIDXLamp("FAILED"), "FAILED");
t.equal(ResolveARCIIDXLamp("ASSIST_CLEAR"), "ASSIST CLEAR");
t.equal(ResolveARCIIDXLamp("EASY_CLEAR"), "EASY CLEAR");
t.equal(ResolveARCIIDXLamp("CLEAR"), "CLEAR");
t.equal(ResolveARCIIDXLamp("HARD_CLEAR"), "HARD CLEAR");
t.equal(ResolveARCIIDXLamp("EX_HARD_CLEAR"), "EX HARD CLEAR");
t.equal(ResolveARCIIDXLamp("FULL_COMBO"), "FULL COMBO");
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -2,8 +2,8 @@ import { EmptyObject } from "../../../../../utils/types";
import { ConverterFunction } from "../../common/types";
import p, { PrudenceSchema } from "prudence";
import {
InvalidScoreFailure,
KTDataNotFoundFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "../../../framework/common/converter-failures";
import { FormatPrError } from "../../../../../utils/prudence";
import { ARCIIDXScore } from "./types";
@@ -11,106 +11,106 @@ import { FindChartOnARCID } from "../../../../../utils/queries/charts";
import { FindSongOnIDGuaranteed } from "../../../../../utils/queries/songs";
import { DryScore } from "../../../framework/common/types";
import {
GenericGetGradeAndPercent,
ParseDateFromString,
GenericGetGradeAndPercent,
ParseDateFromString,
} from "../../../framework/common/score-utils";
import { Lamps } from "tachi-common";
// There's a bunch of other useless fields but we don't care
const PR_ArcIIDXScore: PrudenceSchema = {
chart_id: "string",
lamp: p.isIn(
"FULL_COMBO",
"EX_HARD_CLEAR",
"HARD_CLEAR",
"CLEAR",
"EASY_CLEAR",
"ASSIST_CLEAR",
"FAILED",
"NO_PLAY"
),
ex_score: p.isPositiveInteger,
miss_count: p.nullable(p.isPositiveInteger),
timestamp: "string",
chart_id: "string",
lamp: p.isIn(
"FULL_COMBO",
"EX_HARD_CLEAR",
"HARD_CLEAR",
"CLEAR",
"EASY_CLEAR",
"ASSIST_CLEAR",
"FAILED",
"NO_PLAY"
),
ex_score: p.isPositiveInteger,
miss_count: p.nullable(p.isPositiveInteger),
timestamp: "string",
};
export const ConvertAPIArcIIDX: ConverterFunction<unknown, EmptyObject> = async (
data,
context,
importType,
logger
data,
context,
importType,
logger
) => {
const err = p(data, PR_ArcIIDXScore, {}, { throwOnNonObject: false, allowExcessKeys: true });
const err = p(data, PR_ArcIIDXScore, {}, { throwOnNonObject: false, allowExcessKeys: true });
if (err) {
throw new InvalidScoreFailure(FormatPrError(err, "Invalid ARC Score: "));
}
if (err) {
throw new InvalidScoreFailure(FormatPrError(err, "Invalid ARC Score: "));
}
// confirmed by Prudence above.
const score = data as ARCIIDXScore;
// confirmed by Prudence above.
const score = data as ARCIIDXScore;
const chart = await FindChartOnARCID("iidx", score.chart_id);
const chart = await FindChartOnARCID("iidx", score.chart_id);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with chart_id ${score.chart_id}.`,
importType,
data,
context
);
}
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with chart_id ${score.chart_id}.`,
importType,
data,
context
);
}
const song = await FindSongOnIDGuaranteed("iidx", chart.songID, logger);
const song = await FindSongOnIDGuaranteed("iidx", chart.songID, logger);
const { grade, percent } = GenericGetGradeAndPercent("iidx", score.ex_score, chart);
const { grade, percent } = GenericGetGradeAndPercent("iidx", score.ex_score, chart);
const timeAchieved = ParseDateFromString(score.timestamp);
const timeAchieved = ParseDateFromString(score.timestamp);
const lamp = ResolveARCIIDXLamp(score.lamp);
const lamp = ResolveARCIIDXLamp(score.lamp);
const dryScore: DryScore<"iidx:SP" | "iidx:DP"> = {
comment: null,
game: "iidx",
importType,
timeAchieved,
service: "ARC IIDX27",
scoreData: {
grade,
percent,
score: score.ex_score,
hitData: {},
hitMeta: {
bp: score.miss_count,
},
lamp,
},
scoreMeta: {},
};
const dryScore: DryScore<"iidx:SP" | "iidx:DP"> = {
comment: null,
game: "iidx",
importType,
timeAchieved,
service: "ARC IIDX27",
scoreData: {
grade,
percent,
score: score.ex_score,
hitData: {},
hitMeta: {
bp: score.miss_count,
},
lamp,
},
scoreMeta: {},
};
return { song, chart, dryScore };
return { song, chart, dryScore };
};
export function ResolveARCIIDXLamp(lamp: ARCIIDXScore["lamp"]): Lamps["iidx:SP" | "iidx:DP"] {
switch (lamp) {
case "NO_PLAY":
return "NO PLAY";
case "FAILED":
return "FAILED";
case "ASSIST_CLEAR":
return "ASSIST CLEAR";
case "EASY_CLEAR":
return "EASY CLEAR";
case "CLEAR":
return "CLEAR";
case "HARD_CLEAR":
return "HARD CLEAR";
case "EX_HARD_CLEAR":
return "EX HARD CLEAR";
case "FULL_COMBO":
return "FULL COMBO";
}
switch (lamp) {
case "NO_PLAY":
return "NO PLAY";
case "FAILED":
return "FAILED";
case "ASSIST_CLEAR":
return "ASSIST CLEAR";
case "EASY_CLEAR":
return "EASY CLEAR";
case "CLEAR":
return "CLEAR";
case "HARD_CLEAR":
return "HARD CLEAR";
case "EX_HARD_CLEAR":
return "EX HARD CLEAR";
case "FULL_COMBO":
return "FULL COMBO";
}
// theoretically impossible, but a failsafe regardless.
/* istanbul ignore next */
throw new InvalidScoreFailure(`Invalid lamp ${lamp} - Could not resolve.`);
// theoretically impossible, but a failsafe regardless.
/* istanbul ignore next */
throw new InvalidScoreFailure(`Invalid lamp ${lamp} - Could not resolve.`);
}
@@ -9,37 +9,37 @@ import { ParseArcIIDX } from "./parser";
const logger = CreateLogCtx(__filename);
t.test("#ParseArcIIDX", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should iterate over the API.", async (t) => {
const mockArcAPI = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/player_bests?profile_id=profile": {
_links: {
_next: "https://arc.example.com/api/v1/iidx/27/player_bests?profile_id=profile&page=2",
},
_items: [1, 2, 3],
},
"https://arc.example.com/api/v1/iidx/27/player_bests?profile_id=profile&page=2": {
_links: {
_next: null,
},
_items: [4, 5, 6],
},
});
t.test("Should iterate over the API.", async (t) => {
const mockArcAPI = MockJSONFetch({
"https://arc.example.com/api/v1/iidx/27/player_bests?profile_id=profile": {
_links: {
_next: "https://arc.example.com/api/v1/iidx/27/player_bests?profile_id=profile&page=2",
},
_items: [1, 2, 3],
},
"https://arc.example.com/api/v1/iidx/27/player_bests?profile_id=profile&page=2": {
_links: {
_next: null,
},
_items: [4, 5, 6],
},
});
const res = await ParseArcIIDX("profile", logger, mockArcAPI);
const res = await ParseArcIIDX("profile", logger, mockArcAPI);
t.equal(res.game, "iidx");
t.strictSame(res.context, {});
t.equal(res.game, "iidx");
t.strictSame(res.context, {});
const iter = await agta(res.iterable);
const iter = await agta(res.iterable);
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -7,21 +7,21 @@ import { ARC_API_URL, ARC_AUTH_TOKEN } from "../../../../setup/config";
import { CreateArcIIDXClassHandler } from "./class-handler";
export async function ParseArcIIDX(
arcProfileID: string,
logger: KtLogger,
fetch = nodeFetch
arcProfileID: string,
logger: KtLogger,
fetch = nodeFetch
): Promise<ParserFunctionReturns<unknown, EmptyObject>> {
return {
iterable: TraverseKaiAPI(
ARC_API_URL,
// HEROIC VERSE
`/api/v1/iidx/27/player_bests?profile_id=${arcProfileID}`,
ARC_AUTH_TOKEN,
logger,
fetch
),
context: {},
classHandler: await CreateArcIIDXClassHandler(arcProfileID, ARC_AUTH_TOKEN),
game: "iidx",
};
return {
iterable: TraverseKaiAPI(
ARC_API_URL,
// HEROIC VERSE
`/api/v1/iidx/27/player_bests?profile_id=${arcProfileID}`,
ARC_AUTH_TOKEN,
logger,
fetch
),
context: {},
classHandler: await CreateArcIIDXClassHandler(arcProfileID, ARC_AUTH_TOKEN),
game: "iidx",
};
}
@@ -1,17 +1,17 @@
import { integer } from "tachi-common";
export interface ARCIIDXScore {
chart_id: string;
lamp:
| "NO_PLAY"
| "FAILED"
| "ASSIST_CLEAR"
| "EASY_CLEAR"
| "CLEAR"
| "HARD_CLEAR"
| "EX_HARD_CLEAR"
| "FULL_COMBO";
ex_score: integer;
miss_count: integer | null;
timestamp: string;
chart_id: string;
lamp:
| "NO_PLAY"
| "FAILED"
| "ASSIST_CLEAR"
| "EASY_CLEAR"
| "CLEAR"
| "HARD_CLEAR"
| "EX_HARD_CLEAR"
| "FULL_COMBO";
ex_score: integer;
miss_count: integer | null;
timestamp: string;
}
@@ -2,9 +2,9 @@ import t from "tap";
import { CloseAllConnections } from "../../../../../test-utils/close-connections";
import ResetDBState from "../../../../../test-utils/resets";
import {
GetKTDataJSON,
TestingAlbidaADV,
TestingSDVXAlbidaSong,
GetKTDataJSON,
TestingAlbidaADV,
TestingSDVXAlbidaSong,
} from "../../../../../test-utils/test-data";
import CreateLogCtx from "../../../../logger/logger";
import { ConvertAPIArcSDVX, ResolveARCSDVXLamp } from "./converter";
@@ -16,89 +16,89 @@ const logger = CreateLogCtx(__filename);
const arcScore = GetKTDataJSON("./api-arc/sdvx-score.json") as ARCSDVXScore;
t.test("#ConvertAPIArcSDVX", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should convert a valid score", async (t) => {
const res = await ConvertAPIArcSDVX(arcScore, {}, "api/arc-sdvx", logger);
t.test("Should convert a valid score", async (t) => {
const res = await ConvertAPIArcSDVX(arcScore, {}, "api/arc-sdvx", logger);
t.hasStrict(res, {
song: TestingSDVXAlbidaSong,
chart: TestingAlbidaADV,
dryScore: {
comment: null,
game: "sdvx",
importType: "api/arc-sdvx",
timeAchieved: 1567685885077,
service: "ARC SDVX V",
scoreData: {
grade: "AA",
percent: 93.125,
score: 9312500,
hitData: {
critical: 946,
near: 45,
miss: 49,
},
hitMeta: {
btnRate: 89.5,
holdRate: 92.5,
laserRate: 96.5,
maxCombo: 184,
},
lamp: "CLEAR",
},
scoreMeta: {},
},
});
t.hasStrict(res, {
song: TestingSDVXAlbidaSong,
chart: TestingAlbidaADV,
dryScore: {
comment: null,
game: "sdvx",
importType: "api/arc-sdvx",
timeAchieved: 1567685885077,
service: "ARC SDVX V",
scoreData: {
grade: "AA",
percent: 93.125,
score: 9312500,
hitData: {
critical: 946,
near: 45,
miss: 49,
},
hitMeta: {
btnRate: 89.5,
holdRate: 92.5,
laserRate: 96.5,
maxCombo: 184,
},
lamp: "CLEAR",
},
scoreMeta: {},
},
});
t.end();
});
t.end();
});
t.test("Should throw on an invalid score", (t) => {
t.rejects(
() =>
ConvertAPIArcSDVX(
deepmerge(arcScore, { score: "foo" }),
{},
"api/arc-sdvx",
logger
),
{
message: /Invalid ARC Score:/iu,
} as any
);
t.test("Should throw on an invalid score", (t) => {
t.rejects(
() =>
ConvertAPIArcSDVX(
deepmerge(arcScore, { score: "foo" }),
{},
"api/arc-sdvx",
logger
),
{
message: /Invalid ARC Score:/iu,
} as any
);
t.end();
});
t.end();
});
t.test("Should throw on no chart", (t) => {
t.rejects(
() =>
ConvertAPIArcSDVX(
deepmerge(arcScore, { chart_id: "foo" }),
{},
"api/arc-sdvx",
logger
),
{
message: /Could not find chart/iu,
} as any
);
t.test("Should throw on no chart", (t) => {
t.rejects(
() =>
ConvertAPIArcSDVX(
deepmerge(arcScore, { chart_id: "foo" }),
{},
"api/arc-sdvx",
logger
),
{
message: /Could not find chart/iu,
} as any
);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.test("#ResolveARCSDVXLamp", (t) => {
t.equal(ResolveARCSDVXLamp("PLAY"), "FAILED");
t.equal(ResolveARCSDVXLamp("CLEAR"), "CLEAR");
t.equal(ResolveARCSDVXLamp("HC"), "EXCESSIVE CLEAR");
t.equal(ResolveARCSDVXLamp("UC"), "ULTIMATE CHAIN");
t.equal(ResolveARCSDVXLamp("PUC"), "PERFECT ULTIMATE CHAIN");
t.equal(ResolveARCSDVXLamp("PLAY"), "FAILED");
t.equal(ResolveARCSDVXLamp("CLEAR"), "CLEAR");
t.equal(ResolveARCSDVXLamp("HC"), "EXCESSIVE CLEAR");
t.equal(ResolveARCSDVXLamp("UC"), "ULTIMATE CHAIN");
t.equal(ResolveARCSDVXLamp("PUC"), "PERFECT ULTIMATE CHAIN");
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -2,8 +2,8 @@ import { EmptyObject } from "../../../../../utils/types";
import { ConverterFunction } from "../../common/types";
import p, { PrudenceSchema } from "prudence";
import {
InvalidScoreFailure,
KTDataNotFoundFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "../../../framework/common/converter-failures";
import { FormatPrError } from "../../../../../utils/prudence";
import { ARCSDVXScore } from "./types";
@@ -11,86 +11,86 @@ import { FindChartOnARCID } from "../../../../../utils/queries/charts";
import { FindSongOnIDGuaranteed } from "../../../../../utils/queries/songs";
import { DryScore } from "../../../framework/common/types";
import {
GenericGetGradeAndPercent,
ParseDateFromString,
GenericGetGradeAndPercent,
ParseDateFromString,
} from "../../../framework/common/score-utils";
import { Lamps } from "tachi-common";
// There's a bunch of other useless fields but we don't care
const PR_ArcSDVXScore: PrudenceSchema = {
chart_id: "string",
score: p.isBoundedInteger(0, 10_000_000),
lamp: p.isIn("UC", "PUC", "HC", "PLAY", "CLEAR"),
btn_rate: p.isBetween(0, 100),
long_rate: p.isBetween(0, 100),
vol_rate: p.isBetween(0, 100),
critical: p.isPositiveInteger,
near: p.isPositiveInteger,
error: p.isPositiveInteger,
timestamp: "string",
chart_id: "string",
score: p.isBoundedInteger(0, 10_000_000),
lamp: p.isIn("UC", "PUC", "HC", "PLAY", "CLEAR"),
btn_rate: p.isBetween(0, 100),
long_rate: p.isBetween(0, 100),
vol_rate: p.isBetween(0, 100),
critical: p.isPositiveInteger,
near: p.isPositiveInteger,
error: p.isPositiveInteger,
timestamp: "string",
};
export const ConvertAPIArcSDVX: ConverterFunction<unknown, EmptyObject> = async (
data,
context,
importType,
logger
data,
context,
importType,
logger
) => {
const err = p(data, PR_ArcSDVXScore, {}, { throwOnNonObject: false, allowExcessKeys: true });
const err = p(data, PR_ArcSDVXScore, {}, { throwOnNonObject: false, allowExcessKeys: true });
if (err) {
throw new InvalidScoreFailure(FormatPrError(err, "Invalid ARC Score: "));
}
if (err) {
throw new InvalidScoreFailure(FormatPrError(err, "Invalid ARC Score: "));
}
// confirmed by Prudence above.
const score = data as ARCSDVXScore;
// confirmed by Prudence above.
const score = data as ARCSDVXScore;
const chart = await FindChartOnARCID("sdvx", score.chart_id);
const chart = await FindChartOnARCID("sdvx", score.chart_id);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with chart_id ${score.chart_id}.`,
importType,
data,
context
);
}
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with chart_id ${score.chart_id}.`,
importType,
data,
context
);
}
const song = await FindSongOnIDGuaranteed("sdvx", chart.songID, logger);
const song = await FindSongOnIDGuaranteed("sdvx", chart.songID, logger);
const { grade, percent } = GenericGetGradeAndPercent("sdvx", score.score, chart);
const { grade, percent } = GenericGetGradeAndPercent("sdvx", score.score, chart);
const timeAchieved = ParseDateFromString(score.timestamp);
const timeAchieved = ParseDateFromString(score.timestamp);
const lamp = ResolveARCSDVXLamp(score.lamp);
const lamp = ResolveARCSDVXLamp(score.lamp);
const dryScore: DryScore<"sdvx:Single"> = {
comment: null,
game: "sdvx",
importType,
timeAchieved,
service: "ARC SDVX V",
scoreData: {
grade,
percent,
score: score.score,
hitData: {
critical: score.critical,
near: score.near,
miss: score.error,
},
hitMeta: {
btnRate: score.btn_rate,
holdRate: score.long_rate,
laserRate: score.vol_rate,
maxCombo: score.max_chain,
},
lamp,
},
scoreMeta: {},
};
const dryScore: DryScore<"sdvx:Single"> = {
comment: null,
game: "sdvx",
importType,
timeAchieved,
service: "ARC SDVX V",
scoreData: {
grade,
percent,
score: score.score,
hitData: {
critical: score.critical,
near: score.near,
miss: score.error,
},
hitMeta: {
btnRate: score.btn_rate,
holdRate: score.long_rate,
laserRate: score.vol_rate,
maxCombo: score.max_chain,
},
lamp,
},
scoreMeta: {},
};
return { song, chart, dryScore };
return { song, chart, dryScore };
};
/**
@@ -98,18 +98,18 @@ export const ConvertAPIArcSDVX: ConverterFunction<unknown, EmptyObject> = async
* This function takes the score and lamp and rederives the lamp.
*/
export function ResolveARCSDVXLamp(lamp: ARCSDVXScore["lamp"]): Lamps["sdvx:Single"] {
switch (lamp) {
case "PLAY":
return "FAILED";
case "CLEAR":
return "CLEAR";
case "HC":
return "EXCESSIVE CLEAR";
case "UC":
return "ULTIMATE CHAIN";
case "PUC":
return "PERFECT ULTIMATE CHAIN";
}
switch (lamp) {
case "PLAY":
return "FAILED";
case "CLEAR":
return "CLEAR";
case "HC":
return "EXCESSIVE CLEAR";
case "UC":
return "ULTIMATE CHAIN";
case "PUC":
return "PERFECT ULTIMATE CHAIN";
}
throw new InvalidScoreFailure(`Invalid lamp ${lamp} - Could not convert.`);
throw new InvalidScoreFailure(`Invalid lamp ${lamp} - Could not convert.`);
}
@@ -9,38 +9,38 @@ import { ParseArcSDVX } from "./parser";
const logger = CreateLogCtx(__filename);
t.test("#ParseArcSDVX", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should iterate over the API.", async (t) => {
const mockArcAPI = MockJSONFetch({
"https://arc.example.com/api/v1/sdvx/5/player_bests?profile_id=profile": {
_links: {
_next: "https://arc.example.com/api/v1/sdvx/5/player_bests?profile_id=profile&page=2",
},
_items: [1, 2, 3],
},
"https://arc.example.com/api/v1/sdvx/5/player_bests?profile_id=profile&page=2": {
_links: {
_next: null,
},
_items: [4, 5, 6],
},
});
t.test("Should iterate over the API.", async (t) => {
const mockArcAPI = MockJSONFetch({
"https://arc.example.com/api/v1/sdvx/5/player_bests?profile_id=profile": {
_links: {
_next: "https://arc.example.com/api/v1/sdvx/5/player_bests?profile_id=profile&page=2",
},
_items: [1, 2, 3],
},
"https://arc.example.com/api/v1/sdvx/5/player_bests?profile_id=profile&page=2": {
_links: {
_next: null,
},
_items: [4, 5, 6],
},
});
const res = ParseArcSDVX("profile", logger, mockArcAPI);
const res = ParseArcSDVX("profile", logger, mockArcAPI);
t.equal(res.game, "sdvx");
t.strictSame(res.context, {});
t.equal(res.classHandler, null);
t.equal(res.game, "sdvx");
t.strictSame(res.context, {});
t.equal(res.classHandler, null);
const iter = await agta(res.iterable);
const iter = await agta(res.iterable);
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -6,21 +6,21 @@ import { EmptyObject } from "../../../../../utils/types";
import { ARC_API_URL, ARC_AUTH_TOKEN } from "../../../../setup/config";
export function ParseArcSDVX(
arcProfileID: string,
logger: KtLogger,
fetch = nodeFetch
arcProfileID: string,
logger: KtLogger,
fetch = nodeFetch
): ParserFunctionReturns<unknown, EmptyObject> {
return {
iterable: TraverseKaiAPI(
ARC_API_URL,
// VIVID WAVE.
`/api/v1/sdvx/5/player_bests?profile_id=${arcProfileID}`,
ARC_AUTH_TOKEN,
logger,
fetch
),
context: {},
classHandler: null,
game: "sdvx",
};
return {
iterable: TraverseKaiAPI(
ARC_API_URL,
// VIVID WAVE.
`/api/v1/sdvx/5/player_bests?profile_id=${arcProfileID}`,
ARC_AUTH_TOKEN,
logger,
fetch
),
context: {},
classHandler: null,
game: "sdvx",
};
}
@@ -1,15 +1,15 @@
import { integer } from "tachi-common";
export interface ARCSDVXScore {
chart_id: string;
score: integer;
lamp: "CLEAR" | "UC" | "PUC" | "PLAY" | "HC";
btn_rate: number;
long_rate: number;
vol_rate: number;
critical: integer;
near: integer;
error: integer;
max_chain: integer;
timestamp: string;
chart_id: string;
score: integer;
lamp: "CLEAR" | "UC" | "PUC" | "PLAY" | "HC";
btn_rate: number;
long_rate: number;
vol_rate: number;
critical: integer;
near: integer;
error: integer;
max_chain: integer;
timestamp: string;
}
@@ -3,5 +3,5 @@ import { ParseKaiIIDX } from "../../common/api-kai/iidx/parser";
import { KaiAuthDocument } from "tachi-common";
export function ParseEagIIDX(authDoc: KaiAuthDocument, logger: KtLogger) {
return ParseKaiIIDX("EAG", authDoc, logger);
return ParseKaiIIDX("EAG", authDoc, logger);
}
@@ -3,5 +3,5 @@ import { KaiAuthDocument } from "tachi-common";
import { ParseKaiSDVX } from "../../common/api-kai/sdvx/parser";
export function ParseEagSDVX(authDoc: KaiAuthDocument, logger: KtLogger) {
return ParseKaiSDVX("EAG", authDoc, logger);
return ParseKaiSDVX("EAG", authDoc, logger);
}
@@ -3,5 +3,5 @@ import { ParseKaiIIDX } from "../../common/api-kai/iidx/parser";
import { KaiAuthDocument } from "tachi-common";
export function ParseFloIIDX(authDoc: KaiAuthDocument, logger: KtLogger) {
return ParseKaiIIDX("FLO", authDoc, logger);
return ParseKaiIIDX("FLO", authDoc, logger);
}
@@ -3,5 +3,5 @@ import { KaiAuthDocument } from "tachi-common";
import { ParseKaiSDVX } from "../../common/api-kai/sdvx/parser";
export function ParseFloSDVX(authDoc: KaiAuthDocument, logger: KtLogger) {
return ParseKaiSDVX("FLO", authDoc, logger);
return ParseKaiSDVX("FLO", authDoc, logger);
}
@@ -2,9 +2,9 @@ import t from "tap";
import { CloseAllConnections } from "../../../../../../test-utils/close-connections";
import ResetDBState from "../../../../../../test-utils/resets";
import {
GetKTDataJSON,
Testing511Song,
Testing511SPA,
GetKTDataJSON,
Testing511Song,
Testing511SPA,
} from "../../../../../../test-utils/test-data";
import CreateLogCtx from "../../../../../logger/logger";
import { ConvertAPIKaiIIDX } from "./converter";
@@ -16,84 +16,84 @@ const logger = CreateLogCtx(__filename);
const iidxScore = GetKTDataJSON("./api-kai/iidx-score.json");
t.test("#ConvertAPIKaiIIDX", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should return a dryScore on valid input.", async (t) => {
const res = await ConvertAPIKaiIIDX(iidxScore, { service: "FLO" }, "api/flo-iidx", logger);
t.test("Should return a dryScore on valid input.", async (t) => {
const res = await ConvertAPIKaiIIDX(iidxScore, { service: "FLO" }, "api/flo-iidx", logger);
t.hasStrict(res, {
song: Testing511Song,
chart: Testing511SPA,
dryScore: {
comment: null,
game: "iidx",
importType: "api/flo-iidx",
timeAchieved: 1604171450000,
service: "FLO",
scoreData: {
grade: "MAX-",
// percent: 99.87277353689568 floating point,
score: 1570,
lamp: "HARD CLEAR",
hitData: {},
hitMeta: {
fast: null,
slow: null,
bp: 24,
},
},
scoreMeta: {},
},
});
t.hasStrict(res, {
song: Testing511Song,
chart: Testing511SPA,
dryScore: {
comment: null,
game: "iidx",
importType: "api/flo-iidx",
timeAchieved: 1604171450000,
service: "FLO",
scoreData: {
grade: "MAX-",
// percent: 99.87277353689568 floating point,
score: 1570,
lamp: "HARD CLEAR",
hitData: {},
hitMeta: {
fast: null,
slow: null,
bp: 24,
},
},
scoreMeta: {},
},
});
t.end();
});
t.end();
});
t.test("Should throw KTDataNotFound on unknown chart", (t) => {
t.rejects(
() =>
ConvertAPIKaiIIDX(
deepmerge(iidxScore, { music_id: 0 }),
{ service: "FLO" },
"api/flo-iidx",
logger
),
{
message: /Could not find chart with songID 0 \(SP ANOTHER - Version 26\)/u,
} as any
);
t.test("Should throw KTDataNotFound on unknown chart", (t) => {
t.rejects(
() =>
ConvertAPIKaiIIDX(
deepmerge(iidxScore, { music_id: 0 }),
{ service: "FLO" },
"api/flo-iidx",
logger
),
{
message: /Could not find chart with songID 0 \(SP ANOTHER - Version 26\)/u,
} as any
);
t.end();
});
t.end();
});
t.test("Should throw InvalidScoreFailure on invalid score", (t) => {
t.rejects(
() =>
ConvertAPIKaiIIDX(
deepmerge(iidxScore, { music_id: "foo" }),
{ service: "FLO" },
"api/flo-iidx",
logger
),
{
message: /Error: music_id.*Expected a positive integer.* received foo \[string\]/iu,
} as any
);
t.test("Should throw InvalidScoreFailure on invalid score", (t) => {
t.rejects(
() =>
ConvertAPIKaiIIDX(
deepmerge(iidxScore, { music_id: "foo" }),
{ service: "FLO" },
"api/flo-iidx",
logger
),
{
message: /Error: music_id.*Expected a positive integer.* received foo \[string\]/iu,
} as any
);
t.end();
});
t.end();
});
t.test("Should throw InternalFailure on song-chart desync", async (t) => {
await db.songs.iidx.remove({});
t.test("Should throw InternalFailure on song-chart desync", async (t) => {
await db.songs.iidx.remove({});
t.rejects(() => ConvertAPIKaiIIDX(iidxScore, { service: "FLO" }, "api/flo-iidx", logger), {
message: /Song-Chart desync/u,
} as any);
t.rejects(() => ConvertAPIKaiIIDX(iidxScore, { service: "FLO" }, "api/flo-iidx", logger), {
message: /Song-Chart desync/u,
} as any);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -2,121 +2,121 @@ import { FindIIDXChartOnInGameIDVersion } from "../../../../../../utils/queries/
import { KaiContext, KaiIIDXScore } from "../types";
import p from "prudence";
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "../../../../framework/common/converter-failures";
import { FormatPrError } from "../../../../../../utils/prudence";
import { FindSongOnID } from "../../../../../../utils/queries/songs";
import {
GenericGetGradeAndPercent,
ParseDateFromString,
GenericGetGradeAndPercent,
ParseDateFromString,
} from "../../../../framework/common/score-utils";
import { Lamps } from "tachi-common";
import { ConverterFunction } from "../../types";
import { DryScore } from "../../../../framework/common/types";
const PR_KaiIIDXScore = {
music_id: p.isPositiveInteger,
play_style: p.isIn("SINGLE", "DOUBLE"),
difficulty: p.isIn("BEGINNER", "NORMAL", "HYPER", "ANOTHER", "LEGGENDARIA"),
version_played: p.isBoundedInteger(9, 28),
lamp: p.isBoundedInteger(0, 7),
ex_score: p.isPositiveInteger,
miss_count: p.or(p.isPositiveInteger, p.is(-1)),
fast_count: p.nullable(p.isPositiveInteger),
slow_count: p.nullable(p.isPositiveInteger),
timestamp: "string",
music_id: p.isPositiveInteger,
play_style: p.isIn("SINGLE", "DOUBLE"),
difficulty: p.isIn("BEGINNER", "NORMAL", "HYPER", "ANOTHER", "LEGGENDARIA"),
version_played: p.isBoundedInteger(9, 28),
lamp: p.isBoundedInteger(0, 7),
ex_score: p.isPositiveInteger,
miss_count: p.or(p.isPositiveInteger, p.is(-1)),
fast_count: p.nullable(p.isPositiveInteger),
slow_count: p.nullable(p.isPositiveInteger),
timestamp: "string",
};
function ResolveKaiLamp(lamp: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7): Lamps["iidx:SP" | "iidx:DP"] {
switch (lamp) {
case 0:
return "NO PLAY";
case 1:
return "FAILED";
case 3:
return "EASY CLEAR";
case 2:
return "ASSIST CLEAR";
case 4:
return "CLEAR";
case 5:
return "HARD CLEAR";
case 6:
return "EX HARD CLEAR";
case 7:
return "FULL COMBO";
}
switch (lamp) {
case 0:
return "NO PLAY";
case 1:
return "FAILED";
case 3:
return "EASY CLEAR";
case 2:
return "ASSIST CLEAR";
case 4:
return "CLEAR";
case 5:
return "HARD CLEAR";
case 6:
return "EX HARD CLEAR";
case 7:
return "FULL COMBO";
}
}
export const ConvertAPIKaiIIDX: ConverterFunction<unknown, KaiContext> = async (
data,
context,
importType,
logger
data,
context,
importType,
logger
) => {
const err = p(data, PR_KaiIIDXScore, {}, { allowExcessKeys: true });
const err = p(data, PR_KaiIIDXScore, {}, { allowExcessKeys: true });
if (err) {
throw new InvalidScoreFailure(FormatPrError(err));
}
if (err) {
throw new InvalidScoreFailure(FormatPrError(err));
}
// prudence checks this above.
const score = data as KaiIIDXScore;
// prudence checks this above.
const score = data as KaiIIDXScore;
const playtype = score.play_style === "SINGLE" ? "SP" : "DP";
const playtype = score.play_style === "SINGLE" ? "SP" : "DP";
const chart = await FindIIDXChartOnInGameIDVersion(
score.music_id,
playtype,
score.difficulty,
// they send integers like 25, 27 - this will convert to our versions.
score.version_played.toString()
);
const chart = await FindIIDXChartOnInGameIDVersion(
score.music_id,
playtype,
score.difficulty,
// they send integers like 25, 27 - this will convert to our versions.
score.version_played.toString()
);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${score.music_id} (${playtype} ${score.difficulty} - Version ${score.version_played})`,
importType,
data,
context
);
}
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${score.music_id} (${playtype} ${score.difficulty} - Version ${score.version_played})`,
importType,
data,
context
);
}
const song = await FindSongOnID("iidx", chart.songID);
const song = await FindSongOnID("iidx", chart.songID);
if (!song) {
logger.severe(`Song-Chart desync with song ID ${chart.songID} (iidx).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (iidx).`);
}
if (!song) {
logger.severe(`Song-Chart desync with song ID ${chart.songID} (iidx).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (iidx).`);
}
const lamp = ResolveKaiLamp(score.lamp);
const lamp = ResolveKaiLamp(score.lamp);
const { percent, grade } = GenericGetGradeAndPercent("iidx", score.ex_score, chart);
const { percent, grade } = GenericGetGradeAndPercent("iidx", score.ex_score, chart);
const timeAchieved = ParseDateFromString(score.timestamp);
const timeAchieved = ParseDateFromString(score.timestamp);
const dryScore: DryScore<"iidx:SP" | "iidx:DP"> = {
comment: null,
game: "iidx",
importType,
timeAchieved,
service: context.service,
scoreData: {
grade,
percent,
score: score.ex_score,
lamp,
hitData: {},
hitMeta: {
fast: score.fast_count,
slow: score.slow_count,
bp: score.miss_count === -1 ? null : score.miss_count,
},
},
scoreMeta: {},
};
const dryScore: DryScore<"iidx:SP" | "iidx:DP"> = {
comment: null,
game: "iidx",
importType,
timeAchieved,
service: context.service,
scoreData: {
grade,
percent,
score: score.ex_score,
lamp,
hitData: {},
hitMeta: {
fast: score.fast_count,
slow: score.slow_count,
bp: score.miss_count === -1 ? null : score.miss_count,
},
},
scoreMeta: {},
};
return { song, chart, dryScore };
return { song, chart, dryScore };
};
@@ -6,84 +6,84 @@ import CreateLogCtx from "../../../../../logger/logger";
import { ParseKaiIIDX } from "./parser";
const fakeAuth = {
userID: 1,
refreshToken: "foo",
service: "FLO" as const,
token: "bar",
userID: 1,
refreshToken: "foo",
service: "FLO" as const,
token: "bar",
};
const logger = CreateLogCtx(__filename);
t.test("#ParseKaiIIDX", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should iterate over the IIDX FLO API asynchronously.", async (t) => {
const mockFloAPI = MockJSONFetch({
"https://flo.example.com/api/iidx/v2/play_history": {
_links: {
_next: "https://flo.example.com/api/iidx/v2/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://flo.example.com/api/iidx/v2/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
t.test("Should iterate over the IIDX FLO API asynchronously.", async (t) => {
const mockFloAPI = MockJSONFetch({
"https://flo.example.com/api/iidx/v2/play_history": {
_links: {
_next: "https://flo.example.com/api/iidx/v2/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://flo.example.com/api/iidx/v2/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = ParseKaiIIDX("FLO", fakeAuth, logger, mockFloAPI);
const res = ParseKaiIIDX("FLO", fakeAuth, logger, mockFloAPI);
t.equal(res.game, "iidx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "FLO" });
t.equal(res.game, "iidx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "FLO" });
const iter = [];
const iter = [];
for await (const el of res.iterable) {
iter.push(el);
}
for await (const el of res.iterable) {
iter.push(el);
}
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.end();
});
t.test("Should iterate over the IIDX EAG API asynchronously.", async (t) => {
const mockEagAPI = MockJSONFetch({
"https://eag.example.com/api/iidx/v2/play_history": {
_links: {
_next: "https://eag.example.com/api/iidx/v2/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://eag.example.com/api/iidx/v2/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
t.test("Should iterate over the IIDX EAG API asynchronously.", async (t) => {
const mockEagAPI = MockJSONFetch({
"https://eag.example.com/api/iidx/v2/play_history": {
_links: {
_next: "https://eag.example.com/api/iidx/v2/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://eag.example.com/api/iidx/v2/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = ParseKaiIIDX("EAG", fakeAuth, logger, mockEagAPI);
const res = ParseKaiIIDX("EAG", fakeAuth, logger, mockEagAPI);
t.equal(res.game, "iidx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "EAG" });
t.equal(res.game, "iidx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "EAG" });
const iter = [];
const iter = [];
for await (const el of res.iterable) {
iter.push(el);
}
for await (const el of res.iterable) {
iter.push(el);
}
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -7,25 +7,25 @@ import { ParserFunctionReturns } from "../../types";
import { FLO_API_URL, EAG_API_URL } from "../../../../../setup/config";
export function ParseKaiIIDX(
service: "FLO" | "EAG",
authDoc: KaiAuthDocument,
logger: KtLogger,
fetch = nodeFetch
service: "FLO" | "EAG",
authDoc: KaiAuthDocument,
logger: KtLogger,
fetch = nodeFetch
): ParserFunctionReturns<unknown, KaiContext> {
const baseUrl = service === "FLO" ? FLO_API_URL : EAG_API_URL;
const baseUrl = service === "FLO" ? FLO_API_URL : EAG_API_URL;
return {
iterable: TraverseKaiAPI(
baseUrl,
"/api/iidx/v2/play_history",
authDoc.token,
logger,
fetch
),
context: {
service,
},
classHandler: null,
game: "iidx",
};
return {
iterable: TraverseKaiAPI(
baseUrl,
"/api/iidx/v2/play_history",
authDoc.token,
logger,
fetch
),
context: {
service,
},
classHandler: null,
game: "iidx",
};
}
@@ -2,9 +2,9 @@ import t from "tap";
import { CloseAllConnections } from "../../../../../../test-utils/close-connections";
import ResetDBState from "../../../../../../test-utils/resets";
import {
GetKTDataJSON,
TestingAlbidaADV,
TestingSDVXAlbidaSong,
GetKTDataJSON,
TestingAlbidaADV,
TestingSDVXAlbidaSong,
} from "../../../../../../test-utils/test-data";
import CreateLogCtx from "../../../../../logger/logger";
import { ConvertAPIKaiSDVX, ConvertDifficulty, ConvertVersion, ResolveKaiLamp } from "./converter";
@@ -16,118 +16,118 @@ const logger = CreateLogCtx(__filename);
const sdvxScore = GetKTDataJSON("./api-kai/sdvx-score.json");
t.test("#ConvertAPIKaiSDVX", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should return a dryScore on valid input.", async (t) => {
const res = await ConvertAPIKaiSDVX(sdvxScore, { service: "FLO" }, "api/flo-sdvx", logger);
t.test("Should return a dryScore on valid input.", async (t) => {
const res = await ConvertAPIKaiSDVX(sdvxScore, { service: "FLO" }, "api/flo-sdvx", logger);
t.hasStrict(res, {
song: TestingSDVXAlbidaSong,
chart: TestingAlbidaADV,
dryScore: {
comment: null,
game: "sdvx",
importType: "api/flo-sdvx",
timeAchieved: 1598792891000,
service: "FLO",
scoreData: {
grade: "AA",
// percent: 93.10699, floating point
score: 9310699,
lamp: "EXCESSIVE CLEAR",
hitData: {},
hitMeta: {
fast: 70,
slow: 42,
gauge: 90,
maxCombo: 179,
},
},
scoreMeta: {},
},
});
t.hasStrict(res, {
song: TestingSDVXAlbidaSong,
chart: TestingAlbidaADV,
dryScore: {
comment: null,
game: "sdvx",
importType: "api/flo-sdvx",
timeAchieved: 1598792891000,
service: "FLO",
scoreData: {
grade: "AA",
// percent: 93.10699, floating point
score: 9310699,
lamp: "EXCESSIVE CLEAR",
hitData: {},
hitMeta: {
fast: 70,
slow: 42,
gauge: 90,
maxCombo: 179,
},
},
scoreMeta: {},
},
});
t.end();
});
t.end();
});
t.test("Should throw KTDataNotFound on unknown chart", (t) => {
t.rejects(
() =>
ConvertAPIKaiSDVX(
deepmerge(sdvxScore, { music_id: 0 }),
{ service: "FLO" },
"api/flo-sdvx",
logger
),
{
message: /Could not find chart with songID 0 \(ADV - Version vivid\)/u,
} as any
);
t.test("Should throw KTDataNotFound on unknown chart", (t) => {
t.rejects(
() =>
ConvertAPIKaiSDVX(
deepmerge(sdvxScore, { music_id: 0 }),
{ service: "FLO" },
"api/flo-sdvx",
logger
),
{
message: /Could not find chart with songID 0 \(ADV - Version vivid\)/u,
} as any
);
t.end();
});
t.end();
});
t.test("Should throw InvalidScoreFailure on invalid score", (t) => {
t.rejects(
() =>
ConvertAPIKaiSDVX(
deepmerge(sdvxScore, { music_id: "foo" }),
{ service: "FLO" },
"api/flo-sdvx",
logger
),
{
message: /Error: music_id.*Expected a positive integer.* received foo \[string\]/iu,
} as any
);
t.test("Should throw InvalidScoreFailure on invalid score", (t) => {
t.rejects(
() =>
ConvertAPIKaiSDVX(
deepmerge(sdvxScore, { music_id: "foo" }),
{ service: "FLO" },
"api/flo-sdvx",
logger
),
{
message: /Error: music_id.*Expected a positive integer.* received foo \[string\]/iu,
} as any
);
t.end();
});
t.end();
});
t.test("Should throw InternalFailure on song-chart desync", async (t) => {
await db.songs.sdvx.remove({});
t.test("Should throw InternalFailure on song-chart desync", async (t) => {
await db.songs.sdvx.remove({});
t.rejects(() => ConvertAPIKaiSDVX(sdvxScore, { service: "FLO" }, "api/flo-sdvx", logger), {
message: /Song-Chart desync/u,
} as any);
t.rejects(() => ConvertAPIKaiSDVX(sdvxScore, { service: "FLO" }, "api/flo-sdvx", logger), {
message: /Song-Chart desync/u,
} as any);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.test("#ConvertDifficulty", (t) => {
t.equal(ConvertDifficulty(0), "NOV");
t.equal(ConvertDifficulty(1), "ADV");
t.equal(ConvertDifficulty(2), "EXH");
t.equal(ConvertDifficulty(3), "ANY_INF");
t.equal(ConvertDifficulty(4), "MXM");
t.throws(() => ConvertDifficulty(5));
t.equal(ConvertDifficulty(0), "NOV");
t.equal(ConvertDifficulty(1), "ADV");
t.equal(ConvertDifficulty(2), "EXH");
t.equal(ConvertDifficulty(3), "ANY_INF");
t.equal(ConvertDifficulty(4), "MXM");
t.throws(() => ConvertDifficulty(5));
t.end();
t.end();
});
t.test("#ConvertVersion", (t) => {
t.equal(ConvertVersion(0), "booth");
t.equal(ConvertVersion(1), "inf");
t.equal(ConvertVersion(2), "gw");
t.equal(ConvertVersion(3), "heaven");
t.equal(ConvertVersion(4), "vivid");
t.throws(() => ConvertVersion(5));
t.equal(ConvertVersion(0), "booth");
t.equal(ConvertVersion(1), "inf");
t.equal(ConvertVersion(2), "gw");
t.equal(ConvertVersion(3), "heaven");
t.equal(ConvertVersion(4), "vivid");
t.throws(() => ConvertVersion(5));
t.end();
t.end();
});
t.test("#ResolveKaiLamp", (t) => {
t.equal(ResolveKaiLamp(0), "FAILED");
t.equal(ResolveKaiLamp(1), "CLEAR");
t.equal(ResolveKaiLamp(2), "EXCESSIVE CLEAR");
t.equal(ResolveKaiLamp(3), "ULTIMATE CHAIN");
t.equal(ResolveKaiLamp(4), "PERFECT ULTIMATE CHAIN");
t.throws(() => ConvertDifficulty(5));
t.equal(ResolveKaiLamp(0), "FAILED");
t.equal(ResolveKaiLamp(1), "CLEAR");
t.equal(ResolveKaiLamp(2), "EXCESSIVE CLEAR");
t.equal(ResolveKaiLamp(3), "ULTIMATE CHAIN");
t.equal(ResolveKaiLamp(4), "PERFECT ULTIMATE CHAIN");
t.throws(() => ConvertDifficulty(5));
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -2,150 +2,150 @@ import { FindSDVXChartOnInGameIDVersion } from "../../../../../../utils/queries/
import { KaiContext, KaiSDVXScore } from "../types";
import p from "prudence";
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "../../../../framework/common/converter-failures";
import { FormatPrError } from "../../../../../../utils/prudence";
import { FindSongOnID } from "../../../../../../utils/queries/songs";
import {
GenericGetGradeAndPercent,
ParseDateFromString,
GenericGetGradeAndPercent,
ParseDateFromString,
} from "../../../../framework/common/score-utils";
import { Lamps } from "tachi-common";
import { ConverterFunction } from "../../types";
import { DryScore } from "../../../../framework/common/types";
const PR_KaiSDVXScore = {
music_id: p.isPositiveInteger,
music_difficulty: p.isBoundedInteger(0, 4),
played_version: p.isBoundedInteger(1, 5),
clear_type: p.isBoundedInteger(0, 4),
max_chain: p.isPositiveInteger,
score: p.isBoundedInteger(0, 10_000_000),
critical: p.isPositiveInteger,
near: p.isPositiveInteger,
error: p.isPositiveInteger,
early: p.isPositiveInteger,
late: p.isPositiveInteger,
gauge_rate: p.isBoundedInteger(0, 100),
timestamp: "string",
music_id: p.isPositiveInteger,
music_difficulty: p.isBoundedInteger(0, 4),
played_version: p.isBoundedInteger(1, 5),
clear_type: p.isBoundedInteger(0, 4),
max_chain: p.isPositiveInteger,
score: p.isBoundedInteger(0, 10_000_000),
critical: p.isPositiveInteger,
near: p.isPositiveInteger,
error: p.isPositiveInteger,
early: p.isPositiveInteger,
late: p.isPositiveInteger,
gauge_rate: p.isBoundedInteger(0, 100),
timestamp: "string",
};
export const ConvertAPIKaiSDVX: ConverterFunction<unknown, KaiContext> = async (
data,
context,
importType,
logger
data,
context,
importType,
logger
) => {
const err = p(data, PR_KaiSDVXScore, {}, { allowExcessKeys: true });
const err = p(data, PR_KaiSDVXScore, {}, { allowExcessKeys: true });
if (err) {
throw new InvalidScoreFailure(FormatPrError(err));
}
if (err) {
throw new InvalidScoreFailure(FormatPrError(err));
}
// prudence checks this above.
const score = data as KaiSDVXScore;
// prudence checks this above.
const score = data as KaiSDVXScore;
const difficulty = ConvertDifficulty(score.music_difficulty);
const version = ConvertVersion(score.played_version);
const difficulty = ConvertDifficulty(score.music_difficulty);
const version = ConvertVersion(score.played_version);
const chart = await FindSDVXChartOnInGameIDVersion(score.music_id, difficulty, version);
const chart = await FindSDVXChartOnInGameIDVersion(score.music_id, difficulty, version);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${score.music_id} (${difficulty} - Version ${version})`,
importType,
data,
context
);
}
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${score.music_id} (${difficulty} - Version ${version})`,
importType,
data,
context
);
}
const song = await FindSongOnID("sdvx", chart.songID);
const song = await FindSongOnID("sdvx", chart.songID);
if (!song) {
logger.severe(`Song-Chart desync with song ID ${chart.songID} (sdvx).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (sdvx).`);
}
if (!song) {
logger.severe(`Song-Chart desync with song ID ${chart.songID} (sdvx).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (sdvx).`);
}
const lamp = ResolveKaiLamp(score.clear_type);
const lamp = ResolveKaiLamp(score.clear_type);
const { percent, grade } = GenericGetGradeAndPercent("sdvx", score.score, chart);
const { percent, grade } = GenericGetGradeAndPercent("sdvx", score.score, chart);
const timeAchieved = ParseDateFromString(score.timestamp);
const timeAchieved = ParseDateFromString(score.timestamp);
const dryScore: DryScore<"sdvx:Single"> = {
comment: null,
game: "sdvx",
importType,
timeAchieved,
service: context.service,
scoreData: {
grade,
percent,
score: score.score,
lamp,
hitData: {},
hitMeta: {
fast: score.early,
slow: score.late,
gauge: score.gauge_rate,
maxCombo: score.max_chain,
},
},
scoreMeta: {},
};
const dryScore: DryScore<"sdvx:Single"> = {
comment: null,
game: "sdvx",
importType,
timeAchieved,
service: context.service,
scoreData: {
grade,
percent,
score: score.score,
lamp,
hitData: {},
hitMeta: {
fast: score.early,
slow: score.late,
gauge: score.gauge_rate,
maxCombo: score.max_chain,
},
},
scoreMeta: {},
};
return { song, chart, dryScore };
return { song, chart, dryScore };
};
export function ConvertDifficulty(diff: number) {
switch (diff) {
case 0:
return "NOV";
case 1:
return "ADV";
case 2:
return "EXH";
case 3:
return "ANY_INF";
case 4:
return "MXM";
}
switch (diff) {
case 0:
return "NOV";
case 1:
return "ADV";
case 2:
return "EXH";
case 3:
return "ANY_INF";
case 4:
return "MXM";
}
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
}
export function ConvertVersion(ver: number) {
switch (ver) {
case 0:
return "booth";
case 1:
return "inf";
case 2:
return "gw";
case 3:
return "heaven";
case 4:
return "vivid";
}
switch (ver) {
case 0:
return "booth";
case 1:
return "inf";
case 2:
return "gw";
case 3:
return "heaven";
case 4:
return "vivid";
}
throw new InvalidScoreFailure(`Unknown Game Version ${ver}.`);
throw new InvalidScoreFailure(`Unknown Game Version ${ver}.`);
}
export function ResolveKaiLamp(clear: number): Lamps["sdvx:Single"] {
switch (clear) {
case 0:
return "FAILED";
case 1:
return "CLEAR";
case 2:
return "EXCESSIVE CLEAR";
case 3:
return "ULTIMATE CHAIN";
case 4:
return "PERFECT ULTIMATE CHAIN";
}
switch (clear) {
case 0:
return "FAILED";
case 1:
return "CLEAR";
case 2:
return "EXCESSIVE CLEAR";
case 3:
return "ULTIMATE CHAIN";
case 4:
return "PERFECT ULTIMATE CHAIN";
}
throw new InvalidScoreFailure(`Invalid lamp of ${clear} - Could not convert.`);
throw new InvalidScoreFailure(`Invalid lamp of ${clear} - Could not convert.`);
}
@@ -6,84 +6,84 @@ import CreateLogCtx from "../../../../../logger/logger";
import { ParseKaiSDVX } from "./parser";
const fakeAuth = {
userID: 1,
refreshToken: "foo",
service: "FLO" as const,
token: "bar",
userID: 1,
refreshToken: "foo",
service: "FLO" as const,
token: "bar",
};
const logger = CreateLogCtx(__filename);
t.test("#ParseKaiSDVX", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should iterate over the SDVX FLO API asynchronously.", async (t) => {
const mockFloAPI = MockJSONFetch({
"https://flo.example.com/api/sdvx/v1/play_history": {
_links: {
_next: "https://flo.example.com/api/sdvx/v1/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://flo.example.com/api/sdvx/v1/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
t.test("Should iterate over the SDVX FLO API asynchronously.", async (t) => {
const mockFloAPI = MockJSONFetch({
"https://flo.example.com/api/sdvx/v1/play_history": {
_links: {
_next: "https://flo.example.com/api/sdvx/v1/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://flo.example.com/api/sdvx/v1/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = ParseKaiSDVX("FLO", fakeAuth, logger, mockFloAPI);
const res = ParseKaiSDVX("FLO", fakeAuth, logger, mockFloAPI);
t.equal(res.game, "sdvx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "FLO" });
t.equal(res.game, "sdvx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "FLO" });
const iter = [];
const iter = [];
for await (const el of res.iterable) {
iter.push(el);
}
for await (const el of res.iterable) {
iter.push(el);
}
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.end();
});
t.test("Should iterate over the SDVX EAG API asynchronously.", async (t) => {
const mockEagAPI = MockJSONFetch({
"https://eag.example.com/api/sdvx/v1/play_history": {
_links: {
_next: "https://eag.example.com/api/sdvx/v1/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://eag.example.com/api/sdvx/v1/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
t.test("Should iterate over the SDVX EAG API asynchronously.", async (t) => {
const mockEagAPI = MockJSONFetch({
"https://eag.example.com/api/sdvx/v1/play_history": {
_links: {
_next: "https://eag.example.com/api/sdvx/v1/play_history?page=2",
},
_items: [1, 2, 3, 4],
},
"https://eag.example.com/api/sdvx/v1/play_history?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = ParseKaiSDVX("EAG", fakeAuth, logger, mockEagAPI);
const res = ParseKaiSDVX("EAG", fakeAuth, logger, mockEagAPI);
t.equal(res.game, "sdvx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "EAG" });
t.equal(res.game, "sdvx");
t.equal(res.classHandler, null);
t.strictSame(res.context, { service: "EAG" });
const iter = [];
const iter = [];
for await (const el of res.iterable) {
iter.push(el);
}
for await (const el of res.iterable) {
iter.push(el);
}
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.strictSame(iter, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -7,25 +7,25 @@ import { ParserFunctionReturns } from "../../types";
import { EAG_API_URL, FLO_API_URL } from "../../../../../setup/config";
export function ParseKaiSDVX(
service: "FLO" | "EAG",
authDoc: KaiAuthDocument,
logger: KtLogger,
fetch = nodeFetch
service: "FLO" | "EAG",
authDoc: KaiAuthDocument,
logger: KtLogger,
fetch = nodeFetch
): ParserFunctionReturns<unknown, KaiContext> {
const baseUrl = service === "FLO" ? FLO_API_URL : EAG_API_URL;
const baseUrl = service === "FLO" ? FLO_API_URL : EAG_API_URL;
return {
iterable: TraverseKaiAPI(
baseUrl,
"/api/sdvx/v1/play_history",
authDoc.token,
logger,
fetch
),
context: {
service,
},
classHandler: null,
game: "sdvx",
};
return {
iterable: TraverseKaiAPI(
baseUrl,
"/api/sdvx/v1/play_history",
authDoc.token,
logger,
fetch
),
context: {
service,
},
classHandler: null,
game: "sdvx",
};
}
@@ -12,146 +12,146 @@ const logger = CreateLogCtx(__filename);
const fakeAuth = "bar";
t.test("#TraverseKaiAPI", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should traverse the API", async (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: "http://url.com/sub?page=2",
},
_items: [1, 2, 3, 4],
},
"http://url.com/sub?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
t.test("Should traverse the API", async (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: "http://url.com/sub?page=2",
},
_items: [1, 2, 3, 4],
},
"http://url.com/sub?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
const elements = await agta(res);
const elements = await agta(res);
t.strictSame(elements, [1, 2, 3, 4, 5, 6]);
t.strictSame(elements, [1, 2, 3, 4, 5, 6]);
t.end();
});
t.end();
});
t.test("Should throw on attempted SSRF", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: "http://evil.com/sub?page=2",
},
_items: [1, 2, 3, 4],
},
"http://url.com/sub?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.test("Should throw on attempted SSRF", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: "http://evil.com/sub?page=2",
},
_items: [1, 2, 3, 4],
},
"http://url.com/sub?page=2": {
_links: {
_next: null,
},
_items: [5, 6],
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(
() => agta(res),
new ScoreImportFatalError(500, `http://url.com returned invalid data.`)
);
t.rejects(
() => agta(res),
new ScoreImportFatalError(500, `http://url.com returned invalid data.`)
);
t.end();
});
t.end();
});
t.test("Should throw on invalid response JSON", (t) => {
const mockKaiAPI = () => ({ json: null } as any);
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.test("Should throw on invalid response JSON", (t) => {
const mockKaiAPI = () => ({ json: null } as any);
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(() => agta(res));
t.rejects(() => agta(res));
t.end();
});
t.end();
});
t.test("Should throw on request failure", (t) => {
const mockKaiAPI = () => {
throw new Error("Fake Request timeout...");
};
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.test("Should throw on request failure", (t) => {
const mockKaiAPI = () => {
throw new Error("Fake Request timeout...");
};
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(() => agta(res));
t.rejects(() => agta(res));
t.end();
});
t.end();
});
t.test("Should throw on invalid _links", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: null,
},
});
t.test("Should throw on invalid _links", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: null,
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(() => agta(res));
t.rejects(() => agta(res));
const mockKaiAPI2 = MockJSONFetch({
"http://url.com/sub": {
_links: "foo",
},
});
const mockKaiAPI2 = MockJSONFetch({
"http://url.com/sub": {
_links: "foo",
},
});
const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI2);
const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI2);
t.rejects(() => agta(res2));
t.rejects(() => agta(res2));
t.end();
});
t.end();
});
t.test("Should throw on invalid _links._next", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {},
},
});
t.test("Should throw on invalid _links._next", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {},
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(() => agta(res));
t.rejects(() => agta(res));
const mockKaiAPI2 = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: {},
},
},
});
const mockKaiAPI2 = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: {},
},
},
});
const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI2);
const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI2);
t.rejects(() => agta(res2));
t.rejects(() => agta(res2));
t.end();
});
t.end();
});
t.test("Should throw on invalid _items", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: null,
},
_items: {},
},
});
t.test("Should throw on invalid _items", (t) => {
const mockKaiAPI = MockJSONFetch({
"http://url.com/sub": {
_links: {
_next: null,
},
_items: {},
},
});
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI);
t.rejects(() => agta(res));
t.rejects(() => agta(res));
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -13,75 +13,75 @@ import { CONF_INFO } from "../../../../setup/config";
* that wont make http requests during tests.
*/
export async function* TraverseKaiAPI(
baseUrl: string,
subUrl: string,
token: string,
logger: KtLogger,
fetch = nodeFetch
baseUrl: string,
subUrl: string,
token: string,
logger: KtLogger,
fetch = nodeFetch
) {
let fetchMoreData = true;
let url = `${baseUrl}${subUrl}`;
let fetchMoreData = true;
let url = `${baseUrl}${subUrl}`;
while (fetchMoreData) {
// Rough SSRF check - KAI-like apis could potentially get us to perform
// SSRF. We check if the origin of the next request is exactly the
// original, expected, base URL.
const origin = new URL(url).origin;
while (fetchMoreData) {
// Rough SSRF check - KAI-like apis could potentially get us to perform
// SSRF. We check if the origin of the next request is exactly the
// original, expected, base URL.
const origin = new URL(url).origin;
if (origin !== baseUrl) {
logger.severe(`${baseUrl} attempted SSRF with url ${url}?`);
throw new ScoreImportFatalError(500, `${baseUrl} returned invalid data.`);
}
if (origin !== baseUrl) {
logger.severe(`${baseUrl} attempted SSRF with url ${url}?`);
throw new ScoreImportFatalError(500, `${baseUrl} returned invalid data.`);
}
let json;
// wrap all this in a try catch just incase the fetch or the res
// json call fails.
try {
// eslint-disable-next-line no-await-in-loop
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": `${CONF_INFO.NAME}/${VERSION_STR}`,
},
});
let json;
// wrap all this in a try catch just incase the fetch or the res
// json call fails.
try {
// eslint-disable-next-line no-await-in-loop
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": `${CONF_INFO.NAME}/${VERSION_STR}`,
},
});
// eslint-disable-next-line no-await-in-loop
json = await res.json();
} catch (err) {
logger.error(`Recieved invalid response from ${url}.`, { err });
throw new ScoreImportFatalError(
500,
`Recieved invalid response from ${url}. Are they down?`
);
}
// eslint-disable-next-line no-await-in-loop
json = await res.json();
} catch (err) {
logger.error(`Recieved invalid response from ${url}.`, { err });
throw new ScoreImportFatalError(
500,
`Recieved invalid response from ${url}. Are they down?`
);
}
if (json._links === null || typeof json._links !== "object") {
logger.error(`Recieved invalid JSON from ${url}. Invalid _links.`);
if (json._links === null || typeof json._links !== "object") {
logger.error(`Recieved invalid JSON from ${url}. Invalid _links.`);
throw new ScoreImportFatalError(
500,
`Recieved no _links prop from ${url}. This is not an error with Kamaitachi.`
);
}
throw new ScoreImportFatalError(
500,
`Recieved no _links prop from ${url}. This is not an error with Kamaitachi.`
);
}
if (typeof json._links._next === "string") {
url = json._links._next;
} else if (json._links._next === null) {
// exit the loop after this, we're on the last page.
fetchMoreData = false;
} else {
logger.error(`Recieved invalid response from ${url}. Invalid _links._next.`);
if (typeof json._links._next === "string") {
url = json._links._next;
} else if (json._links._next === null) {
// exit the loop after this, we're on the last page.
fetchMoreData = false;
} else {
logger.error(`Recieved invalid response from ${url}. Invalid _links._next.`);
throw new ScoreImportFatalError(500, `Recieved invalid _links._next prop from ${url}.`);
}
throw new ScoreImportFatalError(500, `Recieved invalid _links._next prop from ${url}.`);
}
if (!Array.isArray(json._items)) {
throw new ScoreImportFatalError(500, `Recieved invalid _items from ${url}.`);
}
if (!Array.isArray(json._items)) {
throw new ScoreImportFatalError(500, `Recieved invalid _items from ${url}.`);
}
// yield everything out of the score array
for (const score of json._items) {
yield score as unknown;
}
}
// yield everything out of the score array
for (const score of json._items) {
yield score as unknown;
}
}
}
@@ -1,35 +1,35 @@
import { integer } from "tachi-common";
export interface KaiIIDXScore {
music_id: integer;
play_style: "SINGLE" | "DOUBLE";
difficulty: "BEGINNER" | "NORMAL" | "HYPER" | "ANOTHER" | "LEGGENDARIA";
version_played: integer;
lamp: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
ex_score: integer;
miss_count: integer; // -1 => null
fast_count: integer | null;
slow_count: integer | null;
timestamp: string;
music_id: integer;
play_style: "SINGLE" | "DOUBLE";
difficulty: "BEGINNER" | "NORMAL" | "HYPER" | "ANOTHER" | "LEGGENDARIA";
version_played: integer;
lamp: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
ex_score: integer;
miss_count: integer; // -1 => null
fast_count: integer | null;
slow_count: integer | null;
timestamp: string;
}
export interface KaiSDVXScore {
music_id: integer;
music_difficulty: 0 | 1 | 2 | 3 | 4;
played_version: integer;
clear_type: 0 | 1 | 2 | 3 | 4; // hm
score: integer;
max_chain: integer;
critical: integer;
near: integer;
error: integer;
early: integer;
late: integer;
gauge_type: 0 | 1 | 2 | 3;
gauge_rate: integer;
timestamp: string;
music_id: integer;
music_difficulty: 0 | 1 | 2 | 3 | 4;
played_version: integer;
clear_type: 0 | 1 | 2 | 3 | 4; // hm
score: integer;
max_chain: integer;
critical: integer;
near: integer;
error: integer;
early: integer;
late: integer;
gauge_type: 0 | 1 | 2 | 3;
gauge_rate: integer;
timestamp: string;
}
export interface KaiContext {
service: "FLO" | "EAG";
service: "FLO" | "EAG";
}
@@ -10,25 +10,25 @@ import { InvalidScoreFailure } from "../../../framework/common/converter-failure
import { CloseAllConnections } from "../../../../../test-utils/close-connections";
const baseBatchManualScore = {
score: 500,
lamp: "HARD CLEAR" as const,
matchType: "kamaitachiSongID" as const,
identifier: "1",
playtype: "SP" as const,
difficulty: "ANOTHER",
score: 500,
lamp: "HARD CLEAR" as const,
matchType: "kamaitachiSongID" as const,
identifier: "1",
playtype: "SP" as const,
difficulty: "ANOTHER",
};
const context = {
game: "iidx" as const,
service: "foo",
version: null,
game: "iidx" as const,
service: "foo",
version: null,
};
const ktdWrap = (msg: string, game: Game = "iidx", version = null): any => ({
importType: "file/batch-manual",
message: new RegExp(EscapeStringRegexp(msg), "u"),
converterContext: { game, service: "foo", version },
data: {}, // any under t.match rules.
importType: "file/batch-manual",
message: new RegExp(EscapeStringRegexp(msg), "u"),
converterContext: { game, service: "foo", version },
data: {}, // any under t.match rules.
});
const logger = CreateLogCtx(__filename);
@@ -36,361 +36,361 @@ const logger = CreateLogCtx(__filename);
const importType = "file/batch-manual" as const;
t.test("#ResolveMatchTypeToKTData", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should resolve for the songID if the matchType is songID", async (t) => {
const res = await ResolveMatchTypeToKTData(
baseBatchManualScore,
context,
importType,
logger
);
t.test("Should resolve for the songID if the matchType is songID", async (t) => {
const res = await ResolveMatchTypeToKTData(
baseBatchManualScore,
context,
importType,
logger
);
t.hasStrict(
res,
{ song: { id: 1 }, chart: Testing511SPA },
"Should return the right song and chart."
);
t.hasStrict(
res,
{ song: { id: 1 }, chart: Testing511SPA },
"Should return the right song and chart."
);
t.rejects(
() =>
ResolveMatchTypeToKTData(
// @ts-expect-error bad
deepmerge(baseBatchManualScore, { identifier: "90000" }),
context,
importType,
logger
),
ktdWrap("Cannot find song with songID 90000")
);
t.rejects(
() =>
ResolveMatchTypeToKTData(
// @ts-expect-error bad
deepmerge(baseBatchManualScore, { identifier: "90000" }),
context,
importType,
logger
),
ktdWrap("Cannot find song with songID 90000")
);
t.end();
});
t.end();
});
t.test("Should resolve for the song title if the matchType is songTitle", async (t) => {
const res = await ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, { matchType: "songTitle", identifier: "5.1.1." }),
context,
importType,
logger
);
t.test("Should resolve for the song title if the matchType is songTitle", async (t) => {
const res = await ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, { matchType: "songTitle", identifier: "5.1.1." }),
context,
importType,
logger
);
t.hasStrict(
res,
{ song: { id: 1 }, chart: Testing511SPA },
"Should return the right song and chart."
);
t.hasStrict(
res,
{ song: { id: 1 }, chart: Testing511SPA },
"Should return the right song and chart."
);
t.rejects(
() =>
ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "songTitle",
identifier: "INVALID_TITLE",
}),
context,
importType,
logger
),
ktdWrap("Cannot find song with title INVALID_TITLE")
);
t.rejects(
() =>
ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "songTitle",
identifier: "INVALID_TITLE",
}),
context,
importType,
logger
),
ktdWrap("Cannot find song with title INVALID_TITLE")
);
t.end();
});
t.end();
});
t.test("Should resolve for the bms chartHash if the matchType is bmsChartHash", async (t) => {
const GAZER17MD5 = "38616b85332037cc12924f2ae2840262";
const GAZER17SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d";
t.test("Should resolve for the bms chartHash if the matchType is bmsChartHash", async (t) => {
const GAZER17MD5 = "38616b85332037cc12924f2ae2840262";
const GAZER17SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d";
const gazerSong = GetKTDataJSON("./tachi/bms-gazer-song.json");
const gazerChart = GetKTDataJSON("./tachi/bms-gazer-chart.json");
const gazerSong = GetKTDataJSON("./tachi/bms-gazer-song.json");
const gazerChart = GetKTDataJSON("./tachi/bms-gazer-chart.json");
const bmsContext: any = deepmerge(context, { game: "bms" });
const bmsContext: any = deepmerge(context, { game: "bms" });
const resMD5 = await ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "bmsChartHash",
identifier: GAZER17MD5,
}),
bmsContext,
importType,
logger
);
const resMD5 = await ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "bmsChartHash",
identifier: GAZER17MD5,
}),
bmsContext,
importType,
logger
);
t.hasStrict(
resMD5,
{ song: gazerSong, chart: gazerChart },
"Should return the right song and chart."
);
t.hasStrict(
resMD5,
{ song: gazerSong, chart: gazerChart },
"Should return the right song and chart."
);
const resSHA256 = await ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "bmsChartHash",
identifier: GAZER17SHA256,
}),
bmsContext,
importType,
logger
);
const resSHA256 = await ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "bmsChartHash",
identifier: GAZER17SHA256,
}),
bmsContext,
importType,
logger
);
t.hasStrict(
resSHA256,
{ song: gazerSong, chart: gazerChart },
"Should return the right song and chart."
);
t.hasStrict(
resSHA256,
{ song: gazerSong, chart: gazerChart },
"Should return the right song and chart."
);
t.rejects(
() =>
ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "bmsChartHash",
identifier: "bad_hash",
}),
bmsContext,
importType,
logger
),
ktdWrap("Cannot find chart for hash ", "bms")
);
t.rejects(
() =>
ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "bmsChartHash",
identifier: "bad_hash",
}),
bmsContext,
importType,
logger
),
ktdWrap("Cannot find chart for hash ", "bms")
);
t.end();
});
t.end();
});
t.test("Should reject if ddrSongHash is called without game = ddr", (t) => {
t.rejects(
ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "ddrSongHash",
playtype: "SP",
difficulty: "DIFFICULT",
}),
context,
importType,
logger
),
new InvalidScoreFailure("Cannot use ddrSongHash lookup on iidx.") as any
);
t.test("Should reject if ddrSongHash is called without game = ddr", (t) => {
t.rejects(
ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "ddrSongHash",
playtype: "SP",
difficulty: "DIFFICULT",
}),
context,
importType,
logger
),
new InvalidScoreFailure("Cannot use ddrSongHash lookup on iidx.") as any
);
t.end();
});
t.end();
});
t.test("Should resolve for the ddr songHash if the matchType is ddrSongHash", async (t) => {
const PUTY_ID = "DQlQ1DlPbq900oqdOo8l0d6I1lIOl99l";
t.test("Should resolve for the ddr songHash if the matchType is ddrSongHash", async (t) => {
const PUTY_ID = "DQlQ1DlPbq900oqdOo8l0d6I1lIOl99l";
const res = await ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "ddrSongHash",
identifier: PUTY_ID,
playtype: "SP",
difficulty: "DIFFICULT",
}),
deepmerge(context, { game: "ddr" }),
importType,
logger
);
const res = await ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "ddrSongHash",
identifier: PUTY_ID,
playtype: "SP",
difficulty: "DIFFICULT",
}),
deepmerge(context, { game: "ddr" }),
importType,
logger
);
t.hasStrict(
res,
{
song: { id: 10 },
chart: { chartID: "48024d36bbe76c9fed09c3ffdc19412925d1efd3" },
},
"Should return the right song and chart."
);
t.hasStrict(
res,
{
song: { id: 10 },
chart: { chartID: "48024d36bbe76c9fed09c3ffdc19412925d1efd3" },
},
"Should return the right song and chart."
);
t.rejects(
() =>
ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "ddrSongHash",
identifier: "Bad_ID",
playtype: "SP",
difficulty: "EXPERT",
}),
deepmerge(context, { game: "ddr" }),
importType,
logger
),
ktdWrap("Cannot find chart for songHash", "ddr")
);
t.rejects(
() =>
ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "ddrSongHash",
identifier: "Bad_ID",
playtype: "SP",
difficulty: "EXPERT",
}),
deepmerge(context, { game: "ddr" }),
importType,
logger
),
ktdWrap("Cannot find chart for songHash", "ddr")
);
t.end();
});
t.end();
});
t.test("Should trigger failsave if invalid matchType is provided.", (t) => {
t.rejects(
() =>
ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "BAD_MATCHTYPE",
}),
context,
importType,
logger
),
new InvalidScoreFailure(`Invalid matchType BAD_MATCHTYPE`) as any
);
t.test("Should trigger failsave if invalid matchType is provided.", (t) => {
t.rejects(
() =>
ResolveMatchTypeToKTData(
deepmerge(baseBatchManualScore, {
matchType: "BAD_MATCHTYPE",
}),
context,
importType,
logger
),
new InvalidScoreFailure(`Invalid matchType BAD_MATCHTYPE`) as any
);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.test("#ResolveChartFromSong", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(ResetDBState);
t.test("Should return the chart for the song + ptdf", async (t) => {
const res = await ResolveChartFromSong(
Testing511Song,
baseBatchManualScore, // has playtype + diff
{ game: "iidx", service: "foo", version: null },
importType
);
t.test("Should return the chart for the song + ptdf", async (t) => {
const res = await ResolveChartFromSong(
Testing511Song,
baseBatchManualScore, // has playtype + diff
{ game: "iidx", service: "foo", version: null },
importType
);
t.hasStrict(res, Testing511SPA);
t.hasStrict(res, Testing511SPA);
t.end();
});
t.end();
});
t.test("Should throw an error if no playtype is provided.", (t) => {
t.rejects(
() =>
ResolveChartFromSong(
Testing511Song,
deepmerge(baseBatchManualScore, { playtype: null }),
{ game: "iidx", service: "foo", version: null },
importType
),
new InvalidScoreFailure(
`Missing 'playtype' field, but was necessary for this lookup.`
) as any
);
t.test("Should throw an error if no playtype is provided.", (t) => {
t.rejects(
() =>
ResolveChartFromSong(
Testing511Song,
deepmerge(baseBatchManualScore, { playtype: null }),
{ game: "iidx", service: "foo", version: null },
importType
),
new InvalidScoreFailure(
`Missing 'playtype' field, but was necessary for this lookup.`
) as any
);
t.end();
});
t.end();
});
t.test("Should throw an error if no difficulty is provided.", (t) => {
t.rejects(
() =>
ResolveChartFromSong(
Testing511Song,
deepmerge(baseBatchManualScore, { difficulty: null }),
{ game: "iidx", service: "foo", version: null },
importType
),
new InvalidScoreFailure(
`Missing 'difficulty' field, but was necessary for this lookup.`
) as any
);
t.test("Should throw an error if no difficulty is provided.", (t) => {
t.rejects(
() =>
ResolveChartFromSong(
Testing511Song,
deepmerge(baseBatchManualScore, { difficulty: null }),
{ game: "iidx", service: "foo", version: null },
importType
),
new InvalidScoreFailure(
`Missing 'difficulty' field, but was necessary for this lookup.`
) as any
);
t.end();
});
t.end();
});
t.test("Should throw an error if an invalid difficulty is provided.", (t) => {
t.rejects(
() =>
ResolveChartFromSong(
Testing511Song,
// @ts-expect-error faulty deepmerge types
deepmerge(baseBatchManualScore, { difficulty: "NOT_VALID_DIFFICULTY" }),
{ game: "iidx", service: "foo", version: null },
importType
),
new InvalidScoreFailure(
`Invalid Difficulty for iidx SP - Expected any of BEGINNER, NORMAL, HYPER, ANOTHER, LEGGENDARIA`
) as any
);
t.test("Should throw an error if an invalid difficulty is provided.", (t) => {
t.rejects(
() =>
ResolveChartFromSong(
Testing511Song,
// @ts-expect-error faulty deepmerge types
deepmerge(baseBatchManualScore, { difficulty: "NOT_VALID_DIFFICULTY" }),
{ game: "iidx", service: "foo", version: null },
importType
),
new InvalidScoreFailure(
`Invalid Difficulty for iidx SP - Expected any of BEGINNER, NORMAL, HYPER, ANOTHER, LEGGENDARIA`
) as any
);
t.end();
});
t.end();
});
t.test("Should throw an error if no chart could be found.", (t) => {
t.rejects(
() =>
ResolveChartFromSong(
Testing511Song,
// @ts-expect-error faulty deepmerge types
deepmerge(baseBatchManualScore, { difficulty: "LEGGENDARIA" }), // 511 has no legg (yet, lol)
{ game: "iidx", service: "foo", version: null },
importType
),
ktdWrap("Cannot find chart for 5.1.1. (SP LEGGENDARIA)")
);
t.test("Should throw an error if no chart could be found.", (t) => {
t.rejects(
() =>
ResolveChartFromSong(
Testing511Song,
// @ts-expect-error faulty deepmerge types
deepmerge(baseBatchManualScore, { difficulty: "LEGGENDARIA" }), // 511 has no legg (yet, lol)
{ game: "iidx", service: "foo", version: null },
importType
),
ktdWrap("Cannot find chart for 5.1.1. (SP LEGGENDARIA)")
);
t.end();
});
t.end();
});
t.test("Should successfully lookup if version is provided.", async (t) => {
const res = await ResolveChartFromSong(
Testing511Song,
baseBatchManualScore,
{
game: "iidx",
service: "foo",
version: "27",
},
importType
);
t.test("Should successfully lookup if version is provided.", async (t) => {
const res = await ResolveChartFromSong(
Testing511Song,
baseBatchManualScore,
{
game: "iidx",
service: "foo",
version: "27",
},
importType
);
t.hasStrict(res, Testing511SPA);
t.hasStrict(res, Testing511SPA);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.test("#ConverterFn", (t) => {
t.test("Should produce a DryScore", async (t) => {
const res = await ConverterBatchManual(
baseBatchManualScore,
{ game: "iidx", service: "foo", version: null },
importType,
logger
);
t.test("Should produce a DryScore", async (t) => {
const res = await ConverterBatchManual(
baseBatchManualScore,
{ game: "iidx", service: "foo", version: null },
importType,
logger
);
t.hasStrict(res, {
chart: Testing511SPA,
song: { id: 1 },
dryScore: {
game: "iidx",
service: "foo (BATCH-MANUAL)",
comment: null,
importType: "file/batch-manual",
timeAchieved: null,
scoreData: {
lamp: "HARD CLEAR",
score: 500,
grade: "E",
// percent: 31.5, -- ish, FPA is hard.
hitData: {},
hitMeta: {},
},
scoreMeta: {},
},
});
t.hasStrict(res, {
chart: Testing511SPA,
song: { id: 1 },
dryScore: {
game: "iidx",
service: "foo (BATCH-MANUAL)",
comment: null,
importType: "file/batch-manual",
timeAchieved: null,
scoreData: {
lamp: "HARD CLEAR",
score: 500,
grade: "E",
// percent: 31.5, -- ish, FPA is hard.
hitData: {},
hitMeta: {},
},
scoreMeta: {},
},
});
t.end();
});
t.end();
});
t.test("Should reject a score with > 100%", (t) => {
t.rejects(
() =>
ConverterBatchManual(
// @ts-expect-error broken deepmerge
deepmerge(baseBatchManualScore, { score: 2000 }),
{ game: "iidx", service: "foo", version: null },
importType,
logger
),
{ message: /Invalid percent/u } as any
);
t.test("Should reject a score with > 100%", (t) => {
t.rejects(
() =>
ConverterBatchManual(
// @ts-expect-error broken deepmerge
deepmerge(baseBatchManualScore, { score: 2000 }),
{ game: "iidx", service: "foo", version: null },
importType,
logger
),
{ message: /Invalid percent/u } as any
);
t.end();
});
t.end();
});
t.end();
t.end();
});
t.teardown(CloseAllConnections);
@@ -1,20 +1,20 @@
import { BatchManualContext, BatchManualScore } from "./types";
import { AnyChartDocument, AnySongDocument, ImportTypes } from "tachi-common";
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "../../../framework/common/converter-failures";
import { FindSongOnID, FindSongOnTitleInsensitive } from "../../../../../utils/queries/songs";
import {
FindBMSChartOnHash,
FindChartWithPTDF,
FindChartWithPTDFVersion,
FindDDRChartOnSongHash,
FindBMSChartOnHash,
FindChartWithPTDF,
FindChartWithPTDFVersion,
FindDDRChartOnSongHash,
} from "../../../../../utils/queries/charts";
import {
AssertStrAsDifficulty,
AssertStrAsPositiveInt,
AssertStrAsDifficulty,
AssertStrAsPositiveInt,
} from "../../../framework/common/string-asserts";
import { GenericGetGradeAndPercent } from "../../../framework/common/score-utils";
import { KtLogger } from "../../../../logger/logger";
@@ -28,207 +28,207 @@ import { ConverterFunction } from "../types";
* @returns A BatchManualScore Converter.
*/
export const ConverterBatchManual: ConverterFunction<BatchManualScore, BatchManualContext> = async (
data,
context,
importType,
logger
data,
context,
importType,
logger
) => {
const game = context.game;
const game = context.game;
const { song, chart } = await ResolveMatchTypeToKTData(data, context, importType, logger);
const { song, chart } = await ResolveMatchTypeToKTData(data, context, importType, logger);
const { percent, grade } = GenericGetGradeAndPercent(context.game, data.score, chart);
const { percent, grade } = GenericGetGradeAndPercent(context.game, data.score, chart);
let service = context.service;
let service = context.service;
if (importType === "ir/direct-manual") {
service += " (DIRECT-MANUAL)";
} else if (importType === "file/batch-manual") {
service += " (BATCH-MANUAL)";
}
if (importType === "ir/direct-manual") {
service += " (DIRECT-MANUAL)";
} else if (importType === "file/batch-manual") {
service += " (BATCH-MANUAL)";
}
const dryScore: DryScore = {
game: game,
service,
comment: data.comment ?? null,
importType,
timeAchieved: data.timeAchieved ?? null,
scoreData: {
lamp: data.lamp,
score: data.score,
grade,
percent,
hitData: data.hitData ?? {},
hitMeta: data.hitMeta ?? {},
},
scoreMeta: {}, // @todo #74
};
const dryScore: DryScore = {
game: game,
service,
comment: data.comment ?? null,
importType,
timeAchieved: data.timeAchieved ?? null,
scoreData: {
lamp: data.lamp,
score: data.score,
grade,
percent,
hitData: data.hitData ?? {},
hitMeta: data.hitMeta ?? {},
},
scoreMeta: {}, // @todo #74
};
return {
chart,
song,
dryScore,
};
return {
chart,
song,
dryScore,
};
};
export async function ResolveMatchTypeToKTData(
data: BatchManualScore,
context: BatchManualContext,
importType: ImportTypes,
logger: KtLogger
data: BatchManualScore,
context: BatchManualContext,
importType: ImportTypes,
logger: KtLogger
): Promise<{ song: AnySongDocument; chart: AnyChartDocument }> {
const game = context.game;
const game = context.game;
if (data.matchType === "bmsChartHash" || data.matchType === "hash") {
if (game !== "bms") {
throw new InvalidScoreFailure(`Cannot use bmsChartHash lookup on ${game}.`);
}
if (data.matchType === "bmsChartHash" || data.matchType === "hash") {
if (game !== "bms") {
throw new InvalidScoreFailure(`Cannot use bmsChartHash lookup on ${game}.`);
}
const chart = await FindBMSChartOnHash(data.identifier);
const chart = await FindBMSChartOnHash(data.identifier);
if (!chart) {
throw new KTDataNotFoundFailure(
`Cannot find chart for hash ${data.identifier}.`,
importType,
data,
context
);
}
if (!chart) {
throw new KTDataNotFoundFailure(
`Cannot find chart for hash ${data.identifier}.`,
importType,
data,
context
);
}
const song = await FindSongOnID(game, chart.songID);
const song = await FindSongOnID(game, chart.songID);
if (!song) {
logger.severe(`BMS songID ${chart.songID} has charts but no parent song.`);
throw new InternalFailure(`BMS songID ${chart.songID} has charts but no parent song.`);
}
if (!song) {
logger.severe(`BMS songID ${chart.songID} has charts but no parent song.`);
throw new InternalFailure(`BMS songID ${chart.songID} has charts but no parent song.`);
}
return { chart, song };
} else if (data.matchType === "ddrSongHash" || data.matchType === "songHash") {
if (game !== "ddr") {
throw new InvalidScoreFailure(`Cannot use ddrSongHash lookup on ${game}.`);
}
return { chart, song };
} else if (data.matchType === "ddrSongHash" || data.matchType === "songHash") {
if (game !== "ddr") {
throw new InvalidScoreFailure(`Cannot use ddrSongHash lookup on ${game}.`);
}
if (!data.difficulty) {
throw new InvalidScoreFailure(
`Missing 'difficulty' field, but is needed for ddrSongHash lookup.`
);
}
if (!data.difficulty) {
throw new InvalidScoreFailure(
`Missing 'difficulty' field, but is needed for ddrSongHash lookup.`
);
}
if (!data.playtype) {
throw new InvalidScoreFailure(
`Missing 'playtype' field, but is needed for ddrSongHash lookup.`
);
}
if (!data.playtype) {
throw new InvalidScoreFailure(
`Missing 'playtype' field, but is needed for ddrSongHash lookup.`
);
}
const difficulty = AssertStrAsDifficulty(data.difficulty, game, data.playtype);
const difficulty = AssertStrAsDifficulty(data.difficulty, game, data.playtype);
const chart = await FindDDRChartOnSongHash(data.identifier, data.playtype, difficulty);
const chart = await FindDDRChartOnSongHash(data.identifier, data.playtype, difficulty);
if (!chart) {
throw new KTDataNotFoundFailure(
`Cannot find chart for songHash ${data.identifier} (${data.playtype} ${difficulty}).`,
importType,
data,
context
);
}
if (!chart) {
throw new KTDataNotFoundFailure(
`Cannot find chart for songHash ${data.identifier} (${data.playtype} ${difficulty}).`,
importType,
data,
context
);
}
const song = await FindSongOnID(game, chart.songID);
const song = await FindSongOnID(game, chart.songID);
if (!song) {
logger.severe(`DDR songID ${chart.songID} has charts but no parent song.`);
throw new InternalFailure(`DDR songID ${chart.songID} has charts but no parent song.`);
}
if (!song) {
logger.severe(`DDR songID ${chart.songID} has charts but no parent song.`);
throw new InternalFailure(`DDR songID ${chart.songID} has charts but no parent song.`);
}
return { song, chart };
} else if (data.matchType === "kamaitachiSongID" || data.matchType === "songID") {
const songID = AssertStrAsPositiveInt(
data.identifier,
"Invalid songID - must be a stringified positive integer."
);
return { song, chart };
} else if (data.matchType === "kamaitachiSongID" || data.matchType === "songID") {
const songID = AssertStrAsPositiveInt(
data.identifier,
"Invalid songID - must be a stringified positive integer."
);
const song = await FindSongOnID(game, songID);
const song = await FindSongOnID(game, songID);
if (!song) {
throw new KTDataNotFoundFailure(
`Cannot find song with songID ${data.identifier}.`,
importType,
data,
context
);
}
if (!song) {
throw new KTDataNotFoundFailure(
`Cannot find song with songID ${data.identifier}.`,
importType,
data,
context
);
}
const chart = await ResolveChartFromSong(song, data, context, importType);
const chart = await ResolveChartFromSong(song, data, context, importType);
return { song, chart };
} else if (data.matchType === "songTitle" || data.matchType === "title") {
const song = await FindSongOnTitleInsensitive(game, data.identifier);
return { song, chart };
} else if (data.matchType === "songTitle" || data.matchType === "title") {
const song = await FindSongOnTitleInsensitive(game, data.identifier);
if (!song) {
throw new KTDataNotFoundFailure(
`Cannot find song with title ${data.identifier}.`,
importType,
data,
context
);
}
if (!song) {
throw new KTDataNotFoundFailure(
`Cannot find song with title ${data.identifier}.`,
importType,
data,
context
);
}
const chart = await ResolveChartFromSong(song, data, context, importType);
const chart = await ResolveChartFromSong(song, data, context, importType);
return { song, chart };
}
return { song, chart };
}
logger.error(
`Invalid matchType ${data.matchType} ended up in conversion - should have been rejected by prudence?`
);
// really, this could be a larger error. - it's an internal failure because prudence should reject this.
throw new InvalidScoreFailure(`Invalid matchType ${data.matchType}.`);
logger.error(
`Invalid matchType ${data.matchType} ended up in conversion - should have been rejected by prudence?`
);
// really, this could be a larger error. - it's an internal failure because prudence should reject this.
throw new InvalidScoreFailure(`Invalid matchType ${data.matchType}.`);
}
export async function ResolveChartFromSong(
song: AnySongDocument,
data: BatchManualScore,
context: BatchManualContext,
importType: ImportTypes
song: AnySongDocument,
data: BatchManualScore,
context: BatchManualContext,
importType: ImportTypes
) {
const game = context.game;
const game = context.game;
if (!data.difficulty) {
throw new InvalidScoreFailure(
`Missing 'difficulty' field, but was necessary for this lookup.`
);
}
if (!data.difficulty) {
throw new InvalidScoreFailure(
`Missing 'difficulty' field, but was necessary for this lookup.`
);
}
if (!data.playtype) {
throw new InvalidScoreFailure(
`Missing 'playtype' field, but was necessary for this lookup.`
);
}
if (!data.playtype) {
throw new InvalidScoreFailure(
`Missing 'playtype' field, but was necessary for this lookup.`
);
}
const difficulty = AssertStrAsDifficulty(data.difficulty, game, data.playtype);
const difficulty = AssertStrAsDifficulty(data.difficulty, game, data.playtype);
let chart;
let chart;
if (context.version) {
chart = await FindChartWithPTDFVersion(
game,
song.id,
data.playtype,
difficulty,
context.version
);
} else {
chart = await FindChartWithPTDF(game, song.id, data.playtype, difficulty);
}
if (context.version) {
chart = await FindChartWithPTDFVersion(
game,
song.id,
data.playtype,
difficulty,
context.version
);
} else {
chart = await FindChartWithPTDF(game, song.id, data.playtype, difficulty);
}
if (!chart) {
throw new KTDataNotFoundFailure(
`Cannot find chart for ${song.title} (${data.playtype} ${difficulty})`,
importType,
data,
context
);
}
if (!chart) {
throw new KTDataNotFoundFailure(
`Cannot find chart for ${song.title} (${data.playtype} ${difficulty})`,
importType,
data,
context
);
}
return chart;
return chart;
}

Some files were not shown because too many files have changed in this diff Show More