This commit is contained in:
zkldi
2021-04-16 23:35:58 +01:00
parent 8c606926ed
commit d546ecbaf9
58 changed files with 23311 additions and 23287 deletions
+20 -20
View File
@@ -1,20 +1,20 @@
import { Session, SessionData } from "express-session";
import { KtchiSessionData } from "../../src/types";
export {};
// this only exists for testing context - it doesn't seem to work properly otherwise.
declare module "express-session" {
// Inject additional properties on express-session
interface SessionData {
ktchi: KtchiSessionData;
}
}
declare global {
namespace Express {
interface Request {
session: Session & Partial<SessionData>;
}
}
}
import { Session, SessionData } from "express-session";
import { KtchiSessionData } from "../../src/types";
export {};
// this only exists for testing context - it doesn't seem to work properly otherwise.
declare module "express-session" {
// Inject additional properties on express-session
interface SessionData {
ktchi: KtchiSessionData;
}
}
declare global {
namespace Express {
interface Request {
session: Session & Partial<SessionData>;
}
}
}
+115 -115
View File
@@ -1,115 +1,115 @@
import {
AddNewUserAPIKey,
CreateAPIKey,
CreateInviteCode,
AddNewInvite,
ReinstateInvite,
} from "./auth-core";
import t from "tap";
import db, { CloseConnection } from "../db/db";
import { PrivateUserDocument } from "kamaitachi-common";
import prAssert from "../test-utils/prassert";
import Prudence from "prudence";
import ResetDBState from "../test-utils/reset-db-state";
t.test("#CreateAPIKey", (t) => {
t.match(
CreateAPIKey(),
/[0-9a-f]{20}/,
"Should return a 20 character long lowercase hex string."
);
t.end();
});
t.test("#AddNewUserAPIKey", (t) => {
t.beforeEach(ResetDBState);
t.test("Should insert a new API key into the database", async (t) => {
let data = await AddNewUserAPIKey({ id: 1 } as PrivateUserDocument);
t.not(data, null, "Return is not null");
prAssert(
data,
{
_id: Prudence.any, // lazy, should be isObjID? @todo
apiKey: Prudence.regex(/[0-9a-f]{20}/),
assignedTo: Prudence.is(1),
expireTime: Prudence.is(3176708633264),
permissions: {
selfkey: Prudence.is(true),
admin: Prudence.is(false),
},
},
"Data should match a public API key object"
);
let inDatabase = await db["public-api-keys"].findOne({
_id: data._id,
});
t.strictSame(data, inDatabase, "Data from database is identical to data returned");
t.end();
});
t.end();
});
t.test("#ReinstateInvite", (t) => {
t.beforeEach(ResetDBState);
t.test("Should change the 'consumed' property of an invite to true.", async (t) => {
// mock insert
let inviteDoc = await db.invites.insert({
code: "foobar",
consumed: true,
createdBy: 1,
createdOn: 1,
});
let response = await ReinstateInvite(inviteDoc);
// @ts-expect-error Monks' types are WRONG. this is nModified, not modifiedCount
t.equal(response.nModified, 1, "Should modify one document");
let invite2 = await db.invites.findOne({
code: inviteDoc.code, // lol
});
t.equal(invite2!.consumed, false, "Should no longer be consumed");
t.end();
});
t.end();
});
t.test("#CreateInviteCode", (t) => {
t.match(CreateInviteCode(), /^[0-9a-f]{40}$/, "Invite should be a 40 character hex string.");
t.end();
});
t.test("#AddNewInvite", (t) => {
t.beforeEach(ResetDBState);
t.test("Should create a new invite from a given user", async (t) => {
let userDoc = await db.users.findOne({ id: 1 });
let result = await AddNewInvite(userDoc!);
t.equal(result.createdBy, userDoc!.id, "Invite should be created by the requesting user.");
t.equal(result.consumed, false, "Invite should not be consumed.");
// was created +/- 6 seconds from now. This is perhaps too lenient, but we're only really testing its just around now ish.
t.ok(Math.abs(result.createdOn - Date.now()) <= 6000, "Invite was created roughly now.");
t.match(result.code, /^[0-9a-f]{40}$/, "Invite code should be a 40 character hex string.");
});
t.end();
});
t.teardown(CloseConnection);
import {
AddNewUserAPIKey,
CreateAPIKey,
CreateInviteCode,
AddNewInvite,
ReinstateInvite,
} from "./auth-core";
import t from "tap";
import db, { CloseConnection } from "../db/db";
import { PrivateUserDocument } from "kamaitachi-common";
import prAssert from "../test-utils/prassert";
import Prudence from "prudence";
import ResetDBState from "../test-utils/reset-db-state";
t.test("#CreateAPIKey", (t) => {
t.match(
CreateAPIKey(),
/[0-9a-f]{20}/,
"Should return a 20 character long lowercase hex string."
);
t.end();
});
t.test("#AddNewUserAPIKey", (t) => {
t.beforeEach(ResetDBState);
t.test("Should insert a new API key into the database", async (t) => {
let data = await AddNewUserAPIKey({ id: 1 } as PrivateUserDocument);
t.not(data, null, "Return is not null");
prAssert(
data,
{
_id: Prudence.any, // lazy, should be isObjID? @todo
apiKey: Prudence.regex(/[0-9a-f]{20}/),
assignedTo: Prudence.is(1),
expireTime: Prudence.is(3176708633264),
permissions: {
selfkey: Prudence.is(true),
admin: Prudence.is(false),
},
},
"Data should match a public API key object"
);
let inDatabase = await db["public-api-keys"].findOne({
_id: data._id,
});
t.strictSame(data, inDatabase, "Data from database is identical to data returned");
t.end();
});
t.end();
});
t.test("#ReinstateInvite", (t) => {
t.beforeEach(ResetDBState);
t.test("Should change the 'consumed' property of an invite to true.", async (t) => {
// mock insert
let inviteDoc = await db.invites.insert({
code: "foobar",
consumed: true,
createdBy: 1,
createdOn: 1,
});
let response = await ReinstateInvite(inviteDoc);
// @ts-expect-error Monks' types are WRONG. this is nModified, not modifiedCount
t.equal(response.nModified, 1, "Should modify one document");
let invite2 = await db.invites.findOne({
code: inviteDoc.code, // lol
});
t.equal(invite2!.consumed, false, "Should no longer be consumed");
t.end();
});
t.end();
});
t.test("#CreateInviteCode", (t) => {
t.match(CreateInviteCode(), /^[0-9a-f]{40}$/, "Invite should be a 40 character hex string.");
t.end();
});
t.test("#AddNewInvite", (t) => {
t.beforeEach(ResetDBState);
t.test("Should create a new invite from a given user", async (t) => {
let userDoc = await db.users.findOne({ id: 1 });
let result = await AddNewInvite(userDoc!);
t.equal(result.createdBy, userDoc!.id, "Invite should be created by the requesting user.");
t.equal(result.consumed, false, "Invite should not be consumed.");
// was created +/- 6 seconds from now. This is perhaps too lenient, but we're only really testing its just around now ish.
t.ok(Math.abs(result.createdOn - Date.now()) <= 6000, "Invite was created roughly now.");
t.match(result.code, /^[0-9a-f]{40}$/, "Invite code should be a 40 character hex string.");
});
t.end();
});
t.teardown(CloseConnection);
+19 -19
View File
@@ -1,19 +1,19 @@
import t from "tap";
import { MockFetch } from "../test-utils/mock-fetch";
import { ValidateCaptcha } from "./captcha-core";
t.test("#ValidateCaptcha", async (t) => {
t.equal(
await ValidateCaptcha("200", "bar", MockFetch({ status: 200 })),
true,
"Validates captcha when status return is 200"
);
t.equal(
await ValidateCaptcha("400", "bar", MockFetch({ status: 400 })),
false,
"Invalidates captcha when status return is not 200"
);
t.end();
});
import t from "tap";
import { MockFetch } from "../test-utils/mock-fetch";
import { ValidateCaptcha } from "./captcha-core";
t.test("#ValidateCaptcha", async (t) => {
t.equal(
await ValidateCaptcha("200", "bar", MockFetch({ status: 200 })),
true,
"Validates captcha when status return is 200"
);
t.equal(
await ValidateCaptcha("400", "bar", MockFetch({ status: 400 })),
false,
"Invalidates captcha when status return is not 200"
);
t.end();
});
+30 -30
View File
@@ -1,30 +1,30 @@
import { GetNextCounterValue } from "./db-core";
import t from "tap";
import db, { CloseConnection } from "../db/db";
import ResetDBState from "../test-utils/reset-db-state";
t.test("#GetNextCounterValue", (t) => {
t.beforeEach(ResetDBState);
t.test("Increments on valid counter hit", async (t) => {
let response = await GetNextCounterValue("real-counter");
// database starts with this at one
t.equal(response, 2, "Counter should return the current number stored");
let dbData = await db.counters.findOne({
counterName: "real-counter",
});
t.equal(dbData!.value, 3, "Counter should increment after being hit");
});
t.rejects(
async () => await GetNextCounterValue("fake-counter"),
"Could not find sequence document for fake-counter."
);
t.end();
});
t.teardown(CloseConnection);
import { GetNextCounterValue } from "./db-core";
import t from "tap";
import db, { CloseConnection } from "../db/db";
import ResetDBState from "../test-utils/reset-db-state";
t.test("#GetNextCounterValue", (t) => {
t.beforeEach(ResetDBState);
t.test("Increments on valid counter hit", async (t) => {
let response = await GetNextCounterValue("real-counter");
// database starts with this at one
t.equal(response, 2, "Counter should return the current number stored");
let dbData = await db.counters.findOne({
counterName: "real-counter",
});
t.equal(dbData!.value, 3, "Counter should increment after being hit");
});
t.rejects(
async () => await GetNextCounterValue("fake-counter"),
"Could not find sequence document for fake-counter."
);
t.end();
});
t.teardown(CloseConnection);
+13 -13
View File
@@ -1,13 +1,13 @@
import { PublicUserDocument } from "kamaitachi-common";
import t from "tap";
import { FormatUserDoc } from "./format-user";
t.test("#FormatUserDoc", (t) => {
t.equal(
FormatUserDoc({ username: "zkldi", id: 123 } as PublicUserDocument),
"zkldi (#123)",
"Should format a user document into username #id format."
);
t.end();
});
import { PublicUserDocument } from "kamaitachi-common";
import t from "tap";
import { FormatUserDoc } from "./format-user";
t.test("#FormatUserDoc", (t) => {
t.equal(
FormatUserDoc({ username: "zkldi", id: 123 } as PublicUserDocument),
"zkldi (#123)",
"Should format a user document into username #id format."
);
t.end();
});
+10 -10
View File
@@ -1,10 +1,10 @@
import { PublicUserDocument } from "kamaitachi-common";
/**
* Returns a formatted string indicating the user. This is used for logging.
* (This is not in user-core.ts to avoid a circular reference issue with logger.ts<->db.ts)
* @param userdoc The user document to format.
*/
export function FormatUserDoc(userdoc: PublicUserDocument) {
return `${userdoc.username} (#${userdoc.id})`;
}
import { PublicUserDocument } from "kamaitachi-common";
/**
* Returns a formatted string indicating the user. This is used for logging.
* (This is not in user-core.ts to avoid a circular reference issue with logger.ts<->db.ts)
* @param userdoc The user document to format.
*/
export function FormatUserDoc(userdoc: PublicUserDocument) {
return `${userdoc.username} (#${userdoc.id})`;
}
+83 -83
View File
@@ -1,83 +1,83 @@
import t from "tap";
import { CloseConnection } from "../db/db";
import { PRUDENCE_PRIVATE_USER, PRUDENCE_PUBLIC_USER } from "../db/schemas";
import prAssert from "../test-utils/prassert";
import {
GetUserCaseInsensitive,
GetUserWithID,
PRIVATEINFO_GetUserCaseInsensitive,
PRIVATEINFO_GetUserWithID,
ResolveUser,
} from "./user-core";
t.test("#GetUserCaseInsensitive", (t) => {
t.test("Should return the user for an exact username", async (t) => {
let result = await GetUserCaseInsensitive("zkldi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "zkldi", "Should return zkldi");
prAssert(result, PRUDENCE_PUBLIC_USER, "Should return a conforming PublicUserDocument");
// @ts-expect-error yeah
t.equal(result.password, undefined, "Should not return password");
// @ts-expect-error yeah
t.equal(result.email, undefined, "Should not return email");
});
t.test("Should return the user for an incorrectly cased username", async (t) => {
let result = await GetUserCaseInsensitive("ZkLdi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "zkldi", "Should return zkldi");
prAssert(result, PRUDENCE_PUBLIC_USER, "Should return a conforming PublicUserDocument");
// @ts-expect-error yeah
t.equal(result.password, undefined, "Should not return password");
// @ts-expect-error yeah
t.equal(result.email, undefined, "Should not return email");
});
t.test("Should not return the user for a username that does not exist", async (t) => {
let result = await GetUserCaseInsensitive("foobar");
t.equal(result, null, "Should return null");
});
t.end();
});
t.test("#GetUserCaseInsensitive", (t) => {
t.test("Should return the user for an exact username", async (t) => {
let result = await PRIVATEINFO_GetUserCaseInsensitive("zkldi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "zkldi", "Should return zkldi");
prAssert(result, PRUDENCE_PRIVATE_USER, "Should return a conforming PrivateUserDocument");
});
t.test("Should return the user for an incorrectly cased username", async (t) => {
let result = await PRIVATEINFO_GetUserCaseInsensitive("ZkLdi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "zkldi", "Should return zkldi");
prAssert(result, PRUDENCE_PRIVATE_USER, "Should return a conforming PrivateUserDocument");
});
t.test("Should not return the user for a username that does not exist", async (t) => {
let result = await PRIVATEINFO_GetUserCaseInsensitive("foobar");
t.equal(result, null, "Should return null");
});
t.end();
});
t.teardown(CloseConnection);
import t from "tap";
import { CloseConnection } from "../db/db";
import { PRUDENCE_PRIVATE_USER, PRUDENCE_PUBLIC_USER } from "../db/schemas";
import prAssert from "../test-utils/prassert";
import {
GetUserCaseInsensitive,
GetUserWithID,
PRIVATEINFO_GetUserCaseInsensitive,
PRIVATEINFO_GetUserWithID,
ResolveUser,
} from "./user-core";
t.test("#GetUserCaseInsensitive", (t) => {
t.test("Should return the user for an exact username", async (t) => {
let result = await GetUserCaseInsensitive("zkldi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "zkldi", "Should return zkldi");
prAssert(result, PRUDENCE_PUBLIC_USER, "Should return a conforming PublicUserDocument");
// @ts-expect-error yeah
t.equal(result.password, undefined, "Should not return password");
// @ts-expect-error yeah
t.equal(result.email, undefined, "Should not return email");
});
t.test("Should return the user for an incorrectly cased username", async (t) => {
let result = await GetUserCaseInsensitive("ZkLdi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "zkldi", "Should return zkldi");
prAssert(result, PRUDENCE_PUBLIC_USER, "Should return a conforming PublicUserDocument");
// @ts-expect-error yeah
t.equal(result.password, undefined, "Should not return password");
// @ts-expect-error yeah
t.equal(result.email, undefined, "Should not return email");
});
t.test("Should not return the user for a username that does not exist", async (t) => {
let result = await GetUserCaseInsensitive("foobar");
t.equal(result, null, "Should return null");
});
t.end();
});
t.test("#GetUserCaseInsensitive", (t) => {
t.test("Should return the user for an exact username", async (t) => {
let result = await PRIVATEINFO_GetUserCaseInsensitive("zkldi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "zkldi", "Should return zkldi");
prAssert(result, PRUDENCE_PRIVATE_USER, "Should return a conforming PrivateUserDocument");
});
t.test("Should return the user for an incorrectly cased username", async (t) => {
let result = await PRIVATEINFO_GetUserCaseInsensitive("ZkLdi");
t.not(result, null, "Should not return null");
t.equal(result!.username, "zkldi", "Should return zkldi");
prAssert(result, PRUDENCE_PRIVATE_USER, "Should return a conforming PrivateUserDocument");
});
t.test("Should not return the user for a username that does not exist", async (t) => {
let result = await PRIVATEINFO_GetUserCaseInsensitive("foobar");
t.equal(result, null, "Should return null");
});
t.end();
});
t.teardown(CloseConnection);
+98 -98
View File
@@ -1,98 +1,98 @@
import { integer, PublicUserDocument } from "kamaitachi-common";
import { FindOneResult } from "monk";
import db from "../db/db";
const OMIT_PRIVATE_USER_RETURNS = {
password: 0,
email: 0,
// legacy protection - this field does not exist on user documents anymore, but it did, and it *did* hold personal information.
integrations: 0,
};
/**
* Gets a user based on their username case-insensitively.
* @param username The username of the user.
* @returns PublicUserDocument
*/
export function GetUserCaseInsensitive(
username: string
): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne(
{
usernameLowercase: username.toLowerCase(),
},
{
projection: OMIT_PRIVATE_USER_RETURNS,
}
) as Promise<FindOneResult<PublicUserDocument>>;
}
/**
* Returns GetUserCaseInsensitive, but without the private field omission.
* @see GetUserCaseInsensitive
* @param username The username of the user.
* @returns PrivateUserDocument
*/
export function PRIVATEINFO_GetUserCaseInsensitive(username: string) {
return db.users.findOne({
usernameLowercase: username.toLowerCase(),
});
}
/**
* Gets a user from their userID.
* @param userID The userID to retrieve the user document of.
* @returns PublicUserDocument
*/
export function GetUserWithID(userID: integer): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne(
{
id: userID,
},
{
projection: OMIT_PRIVATE_USER_RETURNS,
}
) as Promise<FindOneResult<PublicUserDocument>>;
}
/**
* GetUserWithID, but return personal information, too.
* @see GetUserWithID
* @param userID
* @returns PrivateUserDocument
*/
export function PRIVATEINFO_GetUserWithID(userID: integer) {
return db.users.findOne({
id: userID,
});
}
/**
* Gets a user based on either their username case-insensitively, or a direct lookup of their ID.
* This is used in URLs to resolve the passed user.
* @param usernameOrID
*/
export function ResolveUser(usernameOrID: string) {
// user ID passed
if (usernameOrID.match(/^[0-9]$/)) {
let intID = parseInt(usernameOrID, 10);
return db.users.findOne(
{
id: intID,
},
{
projection: OMIT_PRIVATE_USER_RETURNS,
}
);
}
return db.users.findOne(
{
usernameLowercase: usernameOrID,
},
{
projection: OMIT_PRIVATE_USER_RETURNS,
}
);
}
import { integer, PublicUserDocument } from "kamaitachi-common";
import { FindOneResult } from "monk";
import db from "../db/db";
const OMIT_PRIVATE_USER_RETURNS = {
password: 0,
email: 0,
// legacy protection - this field does not exist on user documents anymore, but it did, and it *did* hold personal information.
integrations: 0,
};
/**
* Gets a user based on their username case-insensitively.
* @param username The username of the user.
* @returns PublicUserDocument
*/
export function GetUserCaseInsensitive(
username: string
): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne(
{
usernameLowercase: username.toLowerCase(),
},
{
projection: OMIT_PRIVATE_USER_RETURNS,
}
) as Promise<FindOneResult<PublicUserDocument>>;
}
/**
* Returns GetUserCaseInsensitive, but without the private field omission.
* @see GetUserCaseInsensitive
* @param username The username of the user.
* @returns PrivateUserDocument
*/
export function PRIVATEINFO_GetUserCaseInsensitive(username: string) {
return db.users.findOne({
usernameLowercase: username.toLowerCase(),
});
}
/**
* Gets a user from their userID.
* @param userID The userID to retrieve the user document of.
* @returns PublicUserDocument
*/
export function GetUserWithID(userID: integer): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne(
{
id: userID,
},
{
projection: OMIT_PRIVATE_USER_RETURNS,
}
) as Promise<FindOneResult<PublicUserDocument>>;
}
/**
* GetUserWithID, but return personal information, too.
* @see GetUserWithID
* @param userID
* @returns PrivateUserDocument
*/
export function PRIVATEINFO_GetUserWithID(userID: integer) {
return db.users.findOne({
id: userID,
});
}
/**
* Gets a user based on either their username case-insensitively, or a direct lookup of their ID.
* This is used in URLs to resolve the passed user.
* @param usernameOrID
*/
export function ResolveUser(usernameOrID: string) {
// user ID passed
if (usernameOrID.match(/^[0-9]$/)) {
let intID = parseInt(usernameOrID, 10);
return db.users.findOne(
{
id: intID,
},
{
projection: OMIT_PRIVATE_USER_RETURNS,
}
);
}
return db.users.findOne(
{
usernameLowercase: usernameOrID,
},
{
projection: OMIT_PRIVATE_USER_RETURNS,
}
);
}
+111 -111
View File
@@ -1,111 +1,111 @@
import {
ChartDocument,
config,
CounterDocument,
FolderDocument,
Game,
GoalDocument,
IIDXBPIData,
IIDXEamusementScoreDocument,
ImportDocument,
InviteCodeDocument,
MilestoneDocument,
NotificationDocument,
Playtypes,
PrivateUserDocument,
PublicAPIKeyDocument,
ScoreDocument,
SessionDocument,
SongDocument,
UserGoalDocument,
UserMilestoneDocument,
} from "kamaitachi-common";
import monk, { ICollection } from "monk";
import CreateLogCtx from "../logger";
const logger = CreateLogCtx("db.ts");
const url =
process.env.NODE_ENV === "test" ? "localhost:27017/testingdb" : "localhost:27017/ktblackdb";
let dbtime: [number, number] = [0, 0];
if (process.env.NODE_ENV !== "test") {
logger.info(`Connecting to database ${url}...`);
dbtime = process.hrtime();
}
export let monkDB = monk(url);
if (process.env.NODE_ENV !== "test") {
monkDB.then(() => {
let time = process.hrtime(dbtime);
let elapsed = time[0] + time[1] / 1000000;
logger.info(`Database connection successful: took ${elapsed}ms`);
});
}
export async function ReOpenConnection() {
monkDB = monk(url);
await monkDB.then();
}
export async function CloseConnection() {
await monkDB.close();
}
const StaticCollections = {
scores: monkDB.get<ScoreDocument>("scores"),
folders: monkDB.get<FolderDocument>("folders"),
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"),
notifications: monkDB.get<NotificationDocument>("notifications"),
sessions: monkDB.get<SessionDocument>("sessions"),
"iidx-bpi-data": monkDB.get<IIDXBPIData>("iidx-bpi-data"),
"public-api-keys": monkDB.get<PublicAPIKeyDocument>("public-api-keys"),
invites: monkDB.get<InviteCodeDocument>("invites"),
counters: monkDB.get<CounterDocument>("counters"),
"iidx-eam-scores": monkDB.get<IIDXEamusementScoreDocument>("iidx-eam-scores"),
};
type GameCollections = Record<Game, ICollection>;
interface GCPartial {
songs: Partial<GameCollections>;
charts: Partial<GameCollections>;
}
interface GameCollectionFull {
songs: GameCollections;
charts: GameCollections;
}
let GameCollectionPartial: GCPartial = {
songs: {},
// i have to handwrite this out for TS... :(
charts: {
bms: monkDB.get<`bms:${Playtypes["bms"]}`>(`charts-bms`),
chunithm: monkDB.get<`chunithm:${Playtypes["chunithm"]}`>(`charts-chunithm`),
ddr: monkDB.get<`ddr:${Playtypes["ddr"]}`>(`charts-ddr`),
gitadora: monkDB.get<`gitadora:${Playtypes["gitadora"]}`>(`charts-gitadora`),
iidx: monkDB.get<`iidx:${Playtypes["iidx"]}`>(`charts-iidx`),
jubeat: monkDB.get<`jubeat:${Playtypes["jubeat"]}`>(`charts-jubeat`),
maimai: monkDB.get<`maimai:${Playtypes["maimai"]}`>(`charts-maimai`),
museca: monkDB.get<`museca:${Playtypes["museca"]}`>(`charts-museca`),
popn: monkDB.get<`popn:${Playtypes["popn"]}`>(`charts-popn`),
sdvx: monkDB.get<`sdvx:${Playtypes["sdvx"]}`>(`charts-sdvx`),
usc: monkDB.get<`usc:${Playtypes["usc"]}`>(`charts-usc`),
},
};
for (const game of config.supportedGames) {
GameCollectionPartial.songs[game] = monkDB.get<SongDocument>(`songs-${game}`);
}
// a typescript-friendly interface for the database.
const db = Object.assign(StaticCollections, GameCollectionPartial as GameCollectionFull);
export default db;
import {
ChartDocument,
config,
CounterDocument,
FolderDocument,
Game,
GoalDocument,
IIDXBPIData,
IIDXEamusementScoreDocument,
ImportDocument,
InviteCodeDocument,
MilestoneDocument,
NotificationDocument,
Playtypes,
PrivateUserDocument,
PublicAPIKeyDocument,
ScoreDocument,
SessionDocument,
SongDocument,
UserGoalDocument,
UserMilestoneDocument,
} from "kamaitachi-common";
import monk, { ICollection } from "monk";
import CreateLogCtx from "../logger";
const logger = CreateLogCtx("db.ts");
const url =
process.env.NODE_ENV === "test" ? "localhost:27017/testingdb" : "localhost:27017/ktblackdb";
let dbtime: [number, number] = [0, 0];
if (process.env.NODE_ENV !== "test") {
logger.info(`Connecting to database ${url}...`);
dbtime = process.hrtime();
}
export let monkDB = monk(url);
if (process.env.NODE_ENV !== "test") {
monkDB.then(() => {
let time = process.hrtime(dbtime);
let elapsed = time[0] + time[1] / 1000000;
logger.info(`Database connection successful: took ${elapsed}ms`);
});
}
export async function ReOpenConnection() {
monkDB = monk(url);
await monkDB.then();
}
export async function CloseConnection() {
await monkDB.close();
}
const StaticCollections = {
scores: monkDB.get<ScoreDocument>("scores"),
folders: monkDB.get<FolderDocument>("folders"),
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"),
notifications: monkDB.get<NotificationDocument>("notifications"),
sessions: monkDB.get<SessionDocument>("sessions"),
"iidx-bpi-data": monkDB.get<IIDXBPIData>("iidx-bpi-data"),
"public-api-keys": monkDB.get<PublicAPIKeyDocument>("public-api-keys"),
invites: monkDB.get<InviteCodeDocument>("invites"),
counters: monkDB.get<CounterDocument>("counters"),
"iidx-eam-scores": monkDB.get<IIDXEamusementScoreDocument>("iidx-eam-scores"),
};
type GameCollections = Record<Game, ICollection>;
interface GCPartial {
songs: Partial<GameCollections>;
charts: Partial<GameCollections>;
}
interface GameCollectionFull {
songs: GameCollections;
charts: GameCollections;
}
let GameCollectionPartial: GCPartial = {
songs: {},
// i have to handwrite this out for TS... :(
charts: {
bms: monkDB.get<`bms:${Playtypes["bms"]}`>(`charts-bms`),
chunithm: monkDB.get<`chunithm:${Playtypes["chunithm"]}`>(`charts-chunithm`),
ddr: monkDB.get<`ddr:${Playtypes["ddr"]}`>(`charts-ddr`),
gitadora: monkDB.get<`gitadora:${Playtypes["gitadora"]}`>(`charts-gitadora`),
iidx: monkDB.get<`iidx:${Playtypes["iidx"]}`>(`charts-iidx`),
jubeat: monkDB.get<`jubeat:${Playtypes["jubeat"]}`>(`charts-jubeat`),
maimai: monkDB.get<`maimai:${Playtypes["maimai"]}`>(`charts-maimai`),
museca: monkDB.get<`museca:${Playtypes["museca"]}`>(`charts-museca`),
popn: monkDB.get<`popn:${Playtypes["popn"]}`>(`charts-popn`),
sdvx: monkDB.get<`sdvx:${Playtypes["sdvx"]}`>(`charts-sdvx`),
usc: monkDB.get<`usc:${Playtypes["usc"]}`>(`charts-usc`),
},
};
for (const game of config.supportedGames) {
GameCollectionPartial.songs[game] = monkDB.get<SongDocument>(`songs-${game}`);
}
// a typescript-friendly interface for the database.
const db = Object.assign(StaticCollections, GameCollectionPartial as GameCollectionFull);
export default db;
+272 -272
View File
@@ -1,272 +1,272 @@
import deepmerge from "deepmerge";
import { Game, Playtypes } from "kamaitachi-common";
import {
grades,
importTypes,
lamps,
validHitData,
validHitMeta,
} from "kamaitachi-common/js/config";
import p, { PrudenceOptions, PrudenceSchema, ValidationFunction, ValidSchemaValue } from "prudence";
import { RevaluedObject } from "../types";
import db from "./db";
// eslint-disable-next-line no-useless-escape
const LAZY_EMAIL_REGEX = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const PRUDENCE_PUBLIC_USER: PrudenceSchema = {
_id: p.any,
username: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/),
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
);
export const PRUDENCE_IIDX_BPI_DATA = {
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?
};
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.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",
comment: p.nullable(p.isBoundedString(1, 240)),
timeAdded: p.isPositive,
isScorePB: "boolean",
isLampPB: "boolean",
scoreID: "string", // temp
importType: p.isIn(importTypes),
};
type ScoreSchemas = {
[G in Game]: {
[P in Playtypes[G]]: PrudenceSchema;
};
};
const optionalPositiveInt = p.optional(p.isPositiveInteger);
const nullableAndOptional = (fn: ValidSchemaValue) => p.optional(p.nullable(fn));
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.iidx),
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: optionalPositiveInt,
slow: optionalPositiveInt,
maxCombo: optionalPositiveInt,
},
hitMetaMerge
),
});
const PR_SCORE_IIDX_SP: PrudenceSchema = CreatePRScore(
"iidx",
"SP",
{
bp: optionalPositiveInt,
gauge: p.optional(p.isBetween(0, 100)),
gaugeHistory: p.optional([p.isBetween(0, 100)]),
comboBreak: optionalPositiveInt,
deadMeasure: nullableAndOptional(p.isPositiveInteger),
deadNote: nullableAndOptional(p.isPositiveInteger),
},
{
random: p.optional(p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])),
assist: p.optional(p.isIn(["NO ASSIST", "AUTO SCRATCH", "LEGACY NOTE", "ASCR + LEGACY"])),
range: p.optional(p.isIn(["NONE", "SUDDEN+", "HIDDEN+", "SUD+ HID+", "LIFT", "LIFT SUD+"])),
pacemakerName: "*?string",
pacemakerTarget: nullableAndOptional(p.isPositiveInteger),
}
);
const PR_SCORE_BMS_7K: PrudenceSchema = CreatePRScore(
"bms",
"7K",
{
gauge: p.optional(p.isBetween(0, 100)),
bp: optionalPositiveInt,
diedAt: optionalPositiveInt,
},
{
random: p.optional(p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])),
inputDevice: p.optional(p.isIn(["KB", "BM", "MIDI"])),
}
);
function CreatePRScore<G extends Game>(
game: G,
playtype: Playtypes[G],
mergeHitMeta: PrudenceSchema = {},
mergeScoreMeta: PrudenceSchema = {},
mergeGameSpecific: PrudenceSchema = {}
) {
return deepmerge(PR_SCORE_GENERIC, {
game,
playtype,
scoreData: CreateGameScoreData(game, mergeHitMeta),
scoreMeta: mergeScoreMeta,
calculatedData: {
gameSpecific: mergeGameSpecific,
},
});
}
function DoublePlayTwinRandomTuple(self: unknown) {
if (!Array.isArray(self)) {
return "Expected an array.";
}
if (self.length !== 2) {
return "Expected exactly 2 elements.";
}
let ls = p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])(self[0]);
if (ls !== true) {
return ls;
}
let rs = p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])(self[1]);
return rs;
}
export const PRUDENCE_SCORE_SCHEMAS: ScoreSchemas = {
iidx: {
SP: PR_SCORE_IIDX_SP,
DP: deepmerge(PR_SCORE_IIDX_SP, {
playtype: "DP",
scoreMeta: {
random: p.optional(DoublePlayTwinRandomTuple),
},
}),
},
sdvx: {
Single: CreatePRScore("sdvx", "Single", { gauge: p.optional(p.isBetween(0, 100)) }),
},
bms: {
"7K": PR_SCORE_BMS_7K,
"14K": deepmerge(PR_SCORE_BMS_7K, {
playtype: "14K",
scoreMeta: {
random: p.optional(DoublePlayTwinRandomTuple),
},
}),
"5K": deepmerge(PR_SCORE_BMS_7K, {
playtype: "5K",
scoreMeta: {},
}),
},
chunithm: {
Single: CreatePRScore("chunithm", "Single"),
},
ddr: {
SP: CreatePRScore("ddr", "SP"),
DP: CreatePRScore("ddr", "DP"),
},
gitadora: {
Gita: CreatePRScore("gitadora", "Gita"),
Dora: CreatePRScore("gitadora", "Dora"),
},
jubeat: {
Single: CreatePRScore("jubeat", "Single"),
},
maimai: {
Single: CreatePRScore("maimai", "Single"),
},
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)) }),
},
};
// temporarily partial.
export const SCHEMAS: Partial<RevaluedObject<typeof db, PrudenceSchema>> = {
users: PRUDENCE_PRIVATE_USER,
"iidx-bpi-data": PRUDENCE_IIDX_BPI_DATA,
counters: PRUDENCE_COUNTER,
scores: {
foo: () => "DO NOT USE THIS VALIDATION FUNCTION. USE PRUDENCE_SCORE FORMATS INSTEAD.",
},
};
import deepmerge from "deepmerge";
import { Game, Playtypes } from "kamaitachi-common";
import {
grades,
importTypes,
lamps,
validHitData,
validHitMeta,
} from "kamaitachi-common/js/config";
import p, { PrudenceOptions, PrudenceSchema, ValidationFunction, ValidSchemaValue } from "prudence";
import { RevaluedObject } from "../types";
import db from "./db";
// eslint-disable-next-line no-useless-escape
const LAZY_EMAIL_REGEX = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const PRUDENCE_PUBLIC_USER: PrudenceSchema = {
_id: p.any,
username: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/),
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
);
export const PRUDENCE_IIDX_BPI_DATA = {
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?
};
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.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",
comment: p.nullable(p.isBoundedString(1, 240)),
timeAdded: p.isPositive,
isScorePB: "boolean",
isLampPB: "boolean",
scoreID: "string", // temp
importType: p.isIn(importTypes),
};
type ScoreSchemas = {
[G in Game]: {
[P in Playtypes[G]]: PrudenceSchema;
};
};
const optionalPositiveInt = p.optional(p.isPositiveInteger);
const nullableAndOptional = (fn: ValidSchemaValue) => p.optional(p.nullable(fn));
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.iidx),
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: optionalPositiveInt,
slow: optionalPositiveInt,
maxCombo: optionalPositiveInt,
},
hitMetaMerge
),
});
const PR_SCORE_IIDX_SP: PrudenceSchema = CreatePRScore(
"iidx",
"SP",
{
bp: optionalPositiveInt,
gauge: p.optional(p.isBetween(0, 100)),
gaugeHistory: p.optional([p.isBetween(0, 100)]),
comboBreak: optionalPositiveInt,
deadMeasure: nullableAndOptional(p.isPositiveInteger),
deadNote: nullableAndOptional(p.isPositiveInteger),
},
{
random: p.optional(p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])),
assist: p.optional(p.isIn(["NO ASSIST", "AUTO SCRATCH", "LEGACY NOTE", "ASCR + LEGACY"])),
range: p.optional(p.isIn(["NONE", "SUDDEN+", "HIDDEN+", "SUD+ HID+", "LIFT", "LIFT SUD+"])),
pacemakerName: "*?string",
pacemakerTarget: nullableAndOptional(p.isPositiveInteger),
}
);
const PR_SCORE_BMS_7K: PrudenceSchema = CreatePRScore(
"bms",
"7K",
{
gauge: p.optional(p.isBetween(0, 100)),
bp: optionalPositiveInt,
diedAt: optionalPositiveInt,
},
{
random: p.optional(p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])),
inputDevice: p.optional(p.isIn(["KB", "BM", "MIDI"])),
}
);
function CreatePRScore<G extends Game>(
game: G,
playtype: Playtypes[G],
mergeHitMeta: PrudenceSchema = {},
mergeScoreMeta: PrudenceSchema = {},
mergeGameSpecific: PrudenceSchema = {}
) {
return deepmerge(PR_SCORE_GENERIC, {
game,
playtype,
scoreData: CreateGameScoreData(game, mergeHitMeta),
scoreMeta: mergeScoreMeta,
calculatedData: {
gameSpecific: mergeGameSpecific,
},
});
}
function DoublePlayTwinRandomTuple(self: unknown) {
if (!Array.isArray(self)) {
return "Expected an array.";
}
if (self.length !== 2) {
return "Expected exactly 2 elements.";
}
let ls = p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])(self[0]);
if (ls !== true) {
return ls;
}
let rs = p.isIn(["NONRAN", "RANDOM", "R-RANDOM", "S-RANDOM", "MIRROR"])(self[1]);
return rs;
}
export const PRUDENCE_SCORE_SCHEMAS: ScoreSchemas = {
iidx: {
SP: PR_SCORE_IIDX_SP,
DP: deepmerge(PR_SCORE_IIDX_SP, {
playtype: "DP",
scoreMeta: {
random: p.optional(DoublePlayTwinRandomTuple),
},
}),
},
sdvx: {
Single: CreatePRScore("sdvx", "Single", { gauge: p.optional(p.isBetween(0, 100)) }),
},
bms: {
"7K": PR_SCORE_BMS_7K,
"14K": deepmerge(PR_SCORE_BMS_7K, {
playtype: "14K",
scoreMeta: {
random: p.optional(DoublePlayTwinRandomTuple),
},
}),
"5K": deepmerge(PR_SCORE_BMS_7K, {
playtype: "5K",
scoreMeta: {},
}),
},
chunithm: {
Single: CreatePRScore("chunithm", "Single"),
},
ddr: {
SP: CreatePRScore("ddr", "SP"),
DP: CreatePRScore("ddr", "DP"),
},
gitadora: {
Gita: CreatePRScore("gitadora", "Gita"),
Dora: CreatePRScore("gitadora", "Dora"),
},
jubeat: {
Single: CreatePRScore("jubeat", "Single"),
},
maimai: {
Single: CreatePRScore("maimai", "Single"),
},
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)) }),
},
};
// temporarily partial.
export const SCHEMAS: Partial<RevaluedObject<typeof db, PrudenceSchema>> = {
users: PRUDENCE_PRIVATE_USER,
"iidx-bpi-data": PRUDENCE_IIDX_BPI_DATA,
counters: PRUDENCE_COUNTER,
scores: {
foo: () => "DO NOT USE THIS VALIDATION FUNCTION. USE PRUDENCE_SCORE FORMATS INSTEAD.",
},
};
+10 -10
View File
@@ -1,10 +1,10 @@
import nodeFetch from "node-fetch";
const fetch =
process.env.NODE_ENV === "test"
? () => {
throw new Error("Cannot use real fetch inside testing env!");
}
: nodeFetch;
export default (fetch as unknown) as typeof nodeFetch;
import nodeFetch from "node-fetch";
const fetch =
process.env.NODE_ENV === "test"
? () => {
throw new Error("Cannot use real fetch inside testing env!");
}
: nodeFetch;
export default (fetch as unknown) as typeof nodeFetch;
@@ -0,0 +1,11 @@
import t from "tap";
import mockApi from "../../test-utils/mock-api";
import { TestingIIDXEamusementCSV26 } from "../../test-utils/test-data";
t.test("POST /internal-api/import/file", async (t) => {
let res = await mockApi
.post("/internal-api/import/file")
.attach("file", TestingIIDXEamusementCSV26, "my_csv.csv");
t.end();
});
+133 -133
View File
@@ -1,133 +1,133 @@
import { Router, NextFunction, Request, Response } from "express";
import { FileUploadImportTypes } from "kamaitachi-common";
import multer, { MulterError } from "multer";
import Prudence from "prudence";
import { Logger } from "winston";
import { GetUserWithID } from "../../core/user-core";
import CreateLogCtx from "../../logger";
import prValidate from "../../middleware/prudence-validate";
import { RequireLoggedIn } from "../../middleware/require-logged-in";
import ScoreImportFatalError from "../../score-import/framework/core/score-import-error";
import ScoreImportMain from "../../score-import/framework/score-import-main";
import ParseEamusementCSV from "../../score-import/import-types/csv-eamusement-iidx/parser";
import serverConfig from "../../server-config";
const logger = CreateLogCtx("import.ts");
const router = Router({ mergeParams: true });
// multer config
const upload = multer({ limits: { fileSize: 1024 * 1024 * 16 } }); // basically 16mb
const uploadMW = upload.single("scoreData");
const parseMultipartScoredata = (req: Request, res: Response, next: NextFunction) => {
uploadMW(req, res, (err: unknown) => {
if (err instanceof MulterError) {
logger.error(`Multer Error.`, { err });
return res.status(400).json({
success: false,
description: "File provided was too large, corrupt, or simply broken.",
});
} else if (err) {
logger.error(`Unknown file import error: ${err}`, { err });
return res.status(500).json({
success: false,
description: `An internal server error has occured.`,
});
}
next();
});
};
/**
* Import scores from a file. Expects the post request to be multipart, and to provide a scoreData file.
* @name /internal-api/import/file
*/
router.post(
"/file",
RequireLoggedIn,
parseMultipartScoredata,
prValidate({
importType: Prudence.isIn(serverConfig.supportedFileUploads),
}),
async (req, res) => {
if (!req.file) {
return res.status(400).json({
success: false,
description: `No file provided.`,
});
}
try {
let importType = req.body.importType as FileUploadImportTypes;
const inputParser = async (logger: Logger) =>
await ResolveFileUploadData(importType, req.file, req.body, logger);
const userDoc = await GetUserWithID(req.session.ktchi!.userID);
if (!userDoc) {
logger.severe(
`User ${req.session.ktchi!.userID} does not have an associated user document.`
);
return res.status(500).json({
success: false,
description: "An internal error has occured.",
});
}
let importDocument = await ScoreImportMain(userDoc, importType, inputParser);
return res.status(200).json({
success: true,
description: "Import successful.",
body: {
import: importDocument,
},
});
} catch (err) {
if (err instanceof ScoreImportFatalError) {
return res.status(err.statusCode).json({
success: false,
description: err.message,
});
} else {
logger.error(err);
return res.status(500).json({
success: false,
description: `An internal service error has occured.`,
});
}
}
}
);
/**
* Resolves the data from a file upload into an iterable,
* The appropriate processing function to map that iterable over,
* and and any context the processing may need (such as playtype)
*
* This also performs validation on the type of file uploaded.
* @param importType - The type of import request this was.
* @param fileData - The data sent by the user.
* @param body - Other data passed by the user in the request body.
*/
export function ResolveFileUploadData(
importType: FileUploadImportTypes,
fileData: Express.Multer.File,
body: Record<string, unknown>,
logger: Logger
) {
switch (importType) {
case "csv:eamusement-iidx":
return ParseEamusementCSV(fileData, body, logger);
default:
logger.error(
`importType ${importType} made it into ResolveFileUploadData, but should have been rejected by Prudence.`
);
throw new ScoreImportFatalError(400, `Invalid importType of ${importType}.`);
}
}
export default router;
import { Router, NextFunction, Request, Response } from "express";
import { FileUploadImportTypes } from "kamaitachi-common";
import multer, { MulterError } from "multer";
import Prudence from "prudence";
import { Logger } from "winston";
import { GetUserWithID } from "../../core/user-core";
import CreateLogCtx from "../../logger";
import prValidate from "../../middleware/prudence-validate";
import { RequireLoggedIn } from "../../middleware/require-logged-in";
import ScoreImportFatalError from "../../score-import/framework/core/score-import-error";
import ScoreImportMain from "../../score-import/framework/score-import-main";
import ParseEamusementCSV from "../../score-import/import-types/csv-eamusement-iidx/parser";
import serverConfig from "../../server-config";
const logger = CreateLogCtx("import.ts");
const router = Router({ mergeParams: true });
// multer config
const upload = multer({ limits: { fileSize: 1024 * 1024 * 16 } }); // basically 16mb
const uploadMW = upload.single("scoreData");
const parseMultipartScoredata = (req: Request, res: Response, next: NextFunction) => {
uploadMW(req, res, (err: unknown) => {
if (err instanceof MulterError) {
logger.error(`Multer Error.`, { err });
return res.status(400).json({
success: false,
description: "File provided was too large, corrupt, or simply broken.",
});
} else if (err) {
logger.error(`Unknown file import error: ${err}`, { err });
return res.status(500).json({
success: false,
description: `An internal server error has occured.`,
});
}
next();
});
};
/**
* Import scores from a file. Expects the post request to be multipart, and to provide a scoreData file.
* @name /internal-api/import/file
*/
router.post(
"/file",
RequireLoggedIn,
parseMultipartScoredata,
prValidate({
importType: Prudence.isIn(serverConfig.supportedFileUploads),
}),
async (req, res) => {
if (!req.file) {
return res.status(400).json({
success: false,
description: `No file provided.`,
});
}
try {
let importType = req.body.importType as FileUploadImportTypes;
const inputParser = async (logger: Logger) =>
await ResolveFileUploadData(importType, req.file, req.body, logger);
const userDoc = await GetUserWithID(req.session.ktchi!.userID);
if (!userDoc) {
logger.severe(
`User ${req.session.ktchi!.userID} does not have an associated user document.`
);
return res.status(500).json({
success: false,
description: "An internal error has occured.",
});
}
let importDocument = await ScoreImportMain(userDoc, importType, inputParser);
return res.status(200).json({
success: true,
description: "Import successful.",
body: {
import: importDocument,
},
});
} catch (err) {
if (err instanceof ScoreImportFatalError) {
return res.status(err.statusCode).json({
success: false,
description: err.message,
});
} else {
logger.error(err);
return res.status(500).json({
success: false,
description: `An internal service error has occured.`,
});
}
}
}
);
/**
* Resolves the data from a file upload into an iterable,
* The appropriate processing function to map that iterable over,
* and and any context the processing may need (such as playtype)
*
* This also performs validation on the type of file uploaded.
* @param importType - The type of import request this was.
* @param fileData - The data sent by the user.
* @param body - Other data passed by the user in the request body.
*/
export function ResolveFileUploadData(
importType: FileUploadImportTypes,
fileData: Express.Multer.File,
body: Record<string, unknown>,
logger: Logger
) {
switch (importType) {
case "csv:eamusement-iidx":
return ParseEamusementCSV(fileData, body, logger);
default:
logger.error(
`importType ${importType} made it into ResolveFileUploadData, but should have been rejected by Prudence.`
);
throw new ScoreImportFatalError(400, `Invalid importType of ${importType}.`);
}
}
export default router;
+36 -36
View File
@@ -1,36 +1,36 @@
import { RequireLoggedIn } from "./require-logged-in";
import expMiddlewareMock from "express-request-mock";
import t from "tap";
t.test("#RequireLoggedIn", (t) => {
t.test("Should reject users that are not logged in.", async (t) => {
const { res } = await expMiddlewareMock(RequireLoggedIn, {
session: {
ktchi: null,
},
});
t.equal(res.statusCode, 401, "Should return 401.");
let json = res._getJSONData();
t.strictSame(json, {
success: false,
description: "You are not authorised to perform this action.",
});
});
t.test("Should allow users that are logged in.", async (t) => {
const { res } = await expMiddlewareMock(RequireLoggedIn, {
session: {
ktchi: {
userID: 1,
},
},
});
t.equal(res.statusCode, 200, "Request should still be 200.");
});
t.end();
});
import { RequireLoggedIn } from "./require-logged-in";
import expMiddlewareMock from "express-request-mock";
import t from "tap";
t.test("#RequireLoggedIn", (t) => {
t.test("Should reject users that are not logged in.", async (t) => {
const { res } = await expMiddlewareMock(RequireLoggedIn, {
session: {
ktchi: null,
},
});
t.equal(res.statusCode, 401, "Should return 401.");
let json = res._getJSONData();
t.strictSame(json, {
success: false,
description: "You are not authorised to perform this action.",
});
});
t.test("Should allow users that are logged in.", async (t) => {
const { res } = await expMiddlewareMock(RequireLoggedIn, {
session: {
ktchi: {
userID: 1,
},
},
});
t.equal(res.statusCode, 200, "Request should still be 200.");
});
t.end();
});
+12 -12
View File
@@ -1,12 +1,12 @@
import { NextFunction, Request, Response } from "express";
export function RequireLoggedIn(req: Request, res: Response, next: NextFunction) {
if (!req.session.ktchi?.userID) {
return res.status(401).json({
success: false,
description: `You are not authorised to perform this action.`,
});
}
next();
}
import { NextFunction, Request, Response } from "express";
export function RequireLoggedIn(req: Request, res: Response, next: NextFunction) {
if (!req.session.ktchi?.userID) {
return res.status(401).json({
success: false,
description: `You are not authorised to perform this action.`,
});
}
next();
}
@@ -1,16 +1,16 @@
// Chart "Playtype+Difficulty" lookup. This is a common way of resolving Kamaitachi charts.
import { Difficulties, Game, integer, Playtypes, IDStrings } from "kamaitachi-common";
import db from "../../db/db";
export function FindChartWithPTDF<
G extends Game = Game,
P extends Playtypes[G] = Playtypes[G],
I extends IDStrings = IDStrings
>(game: G, songID: integer, playtype: P, difficulty: Difficulties[I]) {
return db.charts[game].findOne({
songID: songID,
playtype: playtype,
difficulty: difficulty,
});
}
// Chart "Playtype+Difficulty" lookup. This is a common way of resolving Kamaitachi charts.
import { Difficulties, Game, integer, Playtypes, IDStrings } from "kamaitachi-common";
import db from "../../db/db";
export function FindChartWithPTDF<
G extends Game = Game,
P extends Playtypes[G] = Playtypes[G],
I extends IDStrings = IDStrings
>(game: G, songID: integer, playtype: P, difficulty: Difficulties[I]) {
return db.charts[game].findOne({
songID: songID,
playtype: playtype,
difficulty: difficulty,
});
}
@@ -1,46 +1,46 @@
import { Game } from "kamaitachi-common";
import db from "../../db/db";
/**
* Finds a song document for the given game with the given title (or alt-title)
* The third parameter - version - is used to distinguish between older versions of charts
* depending on what version of the .csv is currently being imported.
* @param game - The game to search upon.
* @param title - The song title to match.
* @param version - The version a song should be in to be counted.
* @returns SongDocument
*/
export function FindSongOnTitleVersion(game: Game, title: string, version: string | number) {
return db.songs[game].findOne({
"data.version": version,
$or: [
{
title: title,
},
{
"alt-titles": title,
},
],
});
}
/**
* Finds a song document for the given game with the given title (or alt-title).
* This is NOT the preferred way to find a song, as encodings, and typos, make this
* rather difficult. Prefer other functions!
* @param game - The game to search upon.
* @param title - The song title to match.
* @returns SongDocument
*/
export function FindSongOnTitle(game: Game, title: string) {
return db.songs[game].findOne({
$or: [
{
title: title,
},
{
"alt-titles": title,
},
],
});
}
import { Game } from "kamaitachi-common";
import db from "../../db/db";
/**
* Finds a song document for the given game with the given title (or alt-title)
* The third parameter - version - is used to distinguish between older versions of charts
* depending on what version of the .csv is currently being imported.
* @param game - The game to search upon.
* @param title - The song title to match.
* @param version - The version a song should be in to be counted.
* @returns SongDocument
*/
export function FindSongOnTitleVersion(game: Game, title: string, version: string | number) {
return db.songs[game].findOne({
"data.version": version,
$or: [
{
title: title,
},
{
"alt-titles": title,
},
],
});
}
/**
* Finds a song document for the given game with the given title (or alt-title).
* This is NOT the preferred way to find a song, as encodings, and typos, make this
* rather difficult. Prefer other functions!
* @param game - The game to search upon.
* @param title - The song title to match.
* @returns SongDocument
*/
export function FindSongOnTitle(game: Game, title: string) {
return db.songs[game].findOne({
$or: [
{
title: title,
},
{
"alt-titles": title,
},
],
});
}
@@ -1,57 +1,57 @@
import Pr from "prudence";
import t from "tap";
import { CloseConnection } from "../../../../db/db";
import CreateLogCtx from "../../../../logger";
import prAssert from "../../../../test-utils/prassert";
import {
Testing511SPA,
TestingDoraChart,
TestingGITADORADoraDryScore,
TestingIIDXSPDryScore,
} from "../../../../test-utils/test-data";
import { CreateCalculatedData } from "./calculated-data";
const mockLogger = CreateLogCtx("calculated-data.test.ts");
t.test("#CreateCalculatedData", async (t) => {
let res = await CreateCalculatedData(TestingIIDXSPDryScore, Testing511SPA, mockLogger);
prAssert(
res,
// mock return, we're just checking it returns the right structure, really.
{
rating: Pr.equalTo(0),
lampRating: Pr.equalTo(0),
ranking: "null",
outOf: "null",
gameSpecific: {
BPI: "?number",
KESDC: "?number",
"K%": "?number",
},
},
"Should correctly produce calculatedData"
);
let gitadoraRes = await CreateCalculatedData(
TestingGITADORADoraDryScore,
TestingDoraChart,
mockLogger
);
prAssert(
gitadoraRes,
{
rating: Pr.isPositiveNonZero,
lampRating: Pr.equalTo(0),
ranking: "null",
outOf: "null",
gameSpecific: {},
},
"Should correctly call rating function overrides for different games"
);
t.end();
});
t.teardown(CloseConnection);
import Pr from "prudence";
import t from "tap";
import { CloseConnection } from "../../../../db/db";
import CreateLogCtx from "../../../../logger";
import prAssert from "../../../../test-utils/prassert";
import {
Testing511SPA,
TestingDoraChart,
TestingGITADORADoraDryScore,
TestingIIDXSPDryScore,
} from "../../../../test-utils/test-data";
import { CreateCalculatedData } from "./calculated-data";
const mockLogger = CreateLogCtx("calculated-data.test.ts");
t.test("#CreateCalculatedData", async (t) => {
let res = await CreateCalculatedData(TestingIIDXSPDryScore, Testing511SPA, mockLogger);
prAssert(
res,
// mock return, we're just checking it returns the right structure, really.
{
rating: Pr.equalTo(0),
lampRating: Pr.equalTo(0),
ranking: "null",
outOf: "null",
gameSpecific: {
BPI: "?number",
KESDC: "?number",
"K%": "?number",
},
},
"Should correctly produce calculatedData"
);
let gitadoraRes = await CreateCalculatedData(
TestingGITADORADoraDryScore,
TestingDoraChart,
mockLogger
);
prAssert(
gitadoraRes,
{
rating: Pr.isPositiveNonZero,
lampRating: Pr.equalTo(0),
ranking: "null",
outOf: "null",
gameSpecific: {},
},
"Should correctly call rating function overrides for different games"
);
t.end();
});
t.teardown(CloseConnection);
@@ -1,83 +1,83 @@
import {
AnyChartDocument,
ChartDocument,
Game,
IDStrings,
Playtypes,
ScoreDocument,
SongDocument,
TierlistDataDocument,
} from "kamaitachi-common";
import { Logger } from "winston";
import { DryScore } from "../../../../types";
import { CreateGameSpecific } from "./game-specific";
import { CalculateCHUNITHMRating, CalculateGITADORARating } from "./game-specific-stats";
export async function CreateCalculatedData(
dryScore: DryScore,
chart: AnyChartDocument,
logger: Logger
): Promise<ScoreDocument["calculatedData"]> {
const game = dryScore.game;
const playtype = chart.playtype;
const [rating, gameSpecific] = await Promise.all([
CalculateRating(dryScore, game, playtype, chart),
CreateGameSpecific(game, playtype, chart, dryScore, logger),
]);
return {
rating,
lampRating: 0,
ranking: null,
outOf: null,
gameSpecific,
};
}
/**
* Override the default rating function for a game with
* something else.
*/
const OVERRIDE_RATING_FUNCTIONS: Partial<Record<Game, any>> = {
gitadora: {
Gita: CalculateGITADORARating,
Dora: CalculateGITADORARating,
},
chunithm: {
Single: CalculateCHUNITHMRating,
},
};
/**
* Calculates the rating for a score. Listens to the override functions declared above.
*/
async function CalculateRating<G extends Game>(
dryScore: DryScore,
game: G,
playtype: Playtypes[G],
chart: AnyChartDocument
) {
// @todo
let OverrideFunction: OverrideRatingFunction | undefined =
OVERRIDE_RATING_FUNCTIONS[game]?.[playtype];
if (!OverrideFunction) {
return 0; // go to default kamaitachi rating fn ?
}
return await OverrideFunction(dryScore, chart);
}
// stub
// async function CalculateLampRating(
// dryScore: DryScore,
// chart: ChartDocument,
// tierlist: TierlistDataDocument
// ) {
// return 0;
// }
interface OverrideRatingFunction {
(dryScore: DryScore, chartData: AnyChartDocument): number | Promise<number>;
}
import {
AnyChartDocument,
ChartDocument,
Game,
IDStrings,
Playtypes,
ScoreDocument,
SongDocument,
TierlistDataDocument,
} from "kamaitachi-common";
import { Logger } from "winston";
import { DryScore } from "../../../../types";
import { CreateGameSpecific } from "./game-specific";
import { CalculateCHUNITHMRating, CalculateGITADORARating } from "./game-specific-stats";
export async function CreateCalculatedData(
dryScore: DryScore,
chart: AnyChartDocument,
logger: Logger
): Promise<ScoreDocument["calculatedData"]> {
const game = dryScore.game;
const playtype = chart.playtype;
const [rating, gameSpecific] = await Promise.all([
CalculateRating(dryScore, game, playtype, chart),
CreateGameSpecific(game, playtype, chart, dryScore, logger),
]);
return {
rating,
lampRating: 0,
ranking: null,
outOf: null,
gameSpecific,
};
}
/**
* Override the default rating function for a game with
* something else.
*/
const OVERRIDE_RATING_FUNCTIONS: Partial<Record<Game, any>> = {
gitadora: {
Gita: CalculateGITADORARating,
Dora: CalculateGITADORARating,
},
chunithm: {
Single: CalculateCHUNITHMRating,
},
};
/**
* Calculates the rating for a score. Listens to the override functions declared above.
*/
async function CalculateRating<G extends Game>(
dryScore: DryScore,
game: G,
playtype: Playtypes[G],
chart: AnyChartDocument
) {
// @todo
let OverrideFunction: OverrideRatingFunction | undefined =
OVERRIDE_RATING_FUNCTIONS[game]?.[playtype];
if (!OverrideFunction) {
return 0; // go to default kamaitachi rating fn ?
}
return await OverrideFunction(dryScore, chart);
}
// stub
// async function CalculateLampRating(
// dryScore: DryScore,
// chart: ChartDocument,
// tierlist: TierlistDataDocument
// ) {
// return 0;
// }
interface OverrideRatingFunction {
(dryScore: DryScore, chartData: AnyChartDocument): number | Promise<number>;
}
@@ -1,252 +1,252 @@
import t from "tap";
import { CloseConnection } from "../../../../db/db";
import { DryScore } from "../../../../types";
import { AnyChartDocument, Difficulties, Lamps, ScoreDocument } from "kamaitachi-common";
import {
CalculateBPI,
CalculateCHUNITHMRating,
CalculateGITADORARating,
CalculateKESDC,
CalculateMFCP,
CalculateVF4,
CalculateVF5,
KaidenPercentile,
} from "./game-specific-stats";
import CreateLogCtx from "../../../../logger";
function isApproximately(number: number, target: number, message: string, lenience = 0.01) {
let result = t.ok(Math.abs(number - target) < lenience, message);
if (!result) {
throw new Error(`${number} was not close enough to ${target}`);
}
return true;
}
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;
// 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");
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");
isApproximately(AA_BPI(MAX_AA), 244.56, "A score of MAX should be approximately 244.56BPI");
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;
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");
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");
isApproximately(
AFT_BPI(MAX_AFT),
431.57,
"A score of MAX should be approximately 431.57BPI"
);
t.end();
});
t.end();
});
t.test("#CalculateGITADORARating", (t) => {
function TestGitadoraRating(percent: number, levelNum: number) {
return CalculateGITADORARating(
{ 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(70.76, 5.8),
82.08,
"Test GITADORA Rating function aligns with game (2)"
);
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.end();
});
const mockLogger = CreateLogCtx("game-specific-stats.test.ts");
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,
mockLogger
);
}
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, "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.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", 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", 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.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.end();
});
t.teardown(CloseConnection);
import t from "tap";
import { CloseConnection } from "../../../../db/db";
import { DryScore } from "../../../../types";
import { AnyChartDocument, Difficulties, Lamps, ScoreDocument } from "kamaitachi-common";
import {
CalculateBPI,
CalculateCHUNITHMRating,
CalculateGITADORARating,
CalculateKESDC,
CalculateMFCP,
CalculateVF4,
CalculateVF5,
KaidenPercentile,
} from "./game-specific-stats";
import CreateLogCtx from "../../../../logger";
function isApproximately(number: number, target: number, message: string, lenience = 0.01) {
let result = t.ok(Math.abs(number - target) < lenience, message);
if (!result) {
throw new Error(`${number} was not close enough to ${target}`);
}
return true;
}
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;
// 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");
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");
isApproximately(AA_BPI(MAX_AA), 244.56, "A score of MAX should be approximately 244.56BPI");
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;
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");
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");
isApproximately(
AFT_BPI(MAX_AFT),
431.57,
"A score of MAX should be approximately 431.57BPI"
);
t.end();
});
t.end();
});
t.test("#CalculateGITADORARating", (t) => {
function TestGitadoraRating(percent: number, levelNum: number) {
return CalculateGITADORARating(
{ 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(70.76, 5.8),
82.08,
"Test GITADORA Rating function aligns with game (2)"
);
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.end();
});
const mockLogger = CreateLogCtx("game-specific-stats.test.ts");
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,
mockLogger
);
}
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, "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.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", 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", 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.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.end();
});
t.teardown(CloseConnection);
@@ -1,261 +1,261 @@
import { AnyChartDocument, ChartDocument, ESDCore, integer } from "kamaitachi-common";
import { Logger } from "winston";
import db from "../../../../db/db";
import { DryScore } from "../../../../types";
/**
* Calculates the in-game CHUNITHM rating for a score.
*/
export function CalculateCHUNITHMRating(dryScore: DryScore, chartData: AnyChartDocument) {
let score = dryScore.scoreData.score;
let levelBase = chartData.levelNum * 100;
let val = 0;
if (score >= 1007500) {
val = levelBase + 200;
} else if (score >= 1005000) {
val = levelBase + 150 + ((score - 1005000) * 10) / 500;
} else if (score >= 1000000) {
val = levelBase + 100 + ((score - 1000000) * 5) / 500;
} else if (score >= 975000) {
val = levelBase + ((score - 975000) * 2) / 500;
} else if (score >= 925000) {
val = levelBase - 300 + ((score - 925000) * 3) / 500;
} else if (score >= 900000) {
val = levelBase - 500 + ((score - 900000) * 4) / 500;
} else if (score >= 800000) {
val = (levelBase - 500) / 2 + ((score - 800000) * ((levelBase - 500) / 2)) / 100000;
}
return Math.max(Math.floor(val) / 100, 0);
}
/**
* Calculates the in-game GITADORA rating for a score.
*/
export function CalculateGITADORARating(dryScore: DryScore, chartData: AnyChartDocument) {
let trueRating = (dryScore.scoreData.percent / 100) * chartData.levelNum * 20;
let flooredRating = Math.floor(trueRating * 100) / 100;
return flooredRating;
}
/**
* Calculates the PikaGreatFunction, used in BPI. I have no idea what this does.
* @returns
*/
function BPIPikaGreatFn(score: integer, max: integer) {
return score === max ? max * 0.8 : 1 + (score / max - 0.5) / (1 - score / max);
}
/**
* Oh boy.
*
* Calculates the "Beat Performance Index" of an IIDX score. This algorithm has many issues,
* but is a direct port of Poyashi's implementation for consistencies sake.
* https://github.com/potakusan/iidx_score_manager/blob/f21ba6b85fcc0bf8b7ca888fa2239a3951a9c9c2/src/components/bpi/index.tsx#L120
*
* @param kaidenEx The kaiden average EX score.
* @param wrEx The world record's EX score.
* @param yourEx Your EX score.
* @param max The maximum amount of EX achievable on this chart.
* @param powCoef What power the BPI should be raised to. This is arbitrary, and assigned on a per-song basis. Defaults to 1.175.
* @returns A number between -15 and 100. Unless your score is better than the world record, in which case
* returns can be above 100.
*/
export function CalculateBPI(
kaidenEx: integer,
wrEx: integer,
yourEx: integer,
max: integer,
powCoef: number | null
) {
powCoef ??= 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;
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
);
}
/**
* Calculates the percent of Kaidens you are ahead of with this score.
* @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) {
let scoreCount = await db["iidx-eam-scores"].count({
chartID: chartData.chartID,
});
if (!scoreCount) {
return null;
}
let worseScores = await db["iidx-eam-scores"].count({
chartID: chartData.chartID,
score: { $lt: scoreObj.scoreData.score },
});
return (100 * worseScores) / scoreCount;
}
/**
* An experimental statistic for determining how good a score is versus the Kaiden Average
* 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);
}
/**
* Calculate Marvelous Full Combo Points. This algorithm
* is used in LIFE4, and described here:
* https://life4ddr.com/requirements/#mfcpoints
* @returns Null if this score was not eligible, a number otherwise.
*/
export function CalculateMFCP(dryScore: DryScore, chartData: AnyChartDocument, logger: Logger) {
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;
}
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}.`
);
// 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,
};
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,
};
const VF5LampCoefficients = {
"PERFECT ULTIMATE CHAIN": 1.1,
"ULTIMATE CHAIN": 1.05,
"EXCESSIVE CLEAR": 1.02,
CLEAR: 1.0,
FAILED: 0.5,
};
export function CalculateVF4(
dryScore: DryScore<"sdvx:Single">,
chartData: AnyChartDocument,
logger: Logger
) {
const multiplier = 25;
let level = chartData.levelNum;
let gradeCoefficient = VF4GradeCoefficients[dryScore.scoreData.grade];
if (!gradeCoefficient) {
logger.warn(
`Invalid grade of ${dryScore.scoreData.grade} passed to CalculateVF4. Returning null.`
);
return null;
}
let percent = dryScore.scoreData.percent / 100;
if (!level || !percent) {
return 0;
}
return Math.floor(multiplier * (level + 1) * percent * gradeCoefficient);
}
// WARNING:
// this formula is allegedly tentative according to bemaniwiki.
// idk if it's right, but it must be close enough.
export function CalculateVF5(
dryScore: DryScore<"sdvx:Single">,
chartData: AnyChartDocument,
logger: Logger
) {
let level = chartData.levelNum;
let gradeCoefficient = VF5GradeCoefficients[dryScore.scoreData.grade];
let lampCoefficient = VF5LampCoefficients[dryScore.scoreData.lamp];
if (!lampCoefficient) {
logger.warn(
`Invalid lamp of ${dryScore.scoreData.lamp} passed to CalculateVF5. Returning null.`
);
return null;
}
if (!gradeCoefficient) {
logger.warn(
`Invalid grade of ${dryScore.scoreData.grade} passed to CalculateVF5. Returning null.`
);
return null;
}
let percent = dryScore.scoreData.percent / 100;
if (!level || !percent) {
return 0;
}
return Math.floor(level * 2 * percent * gradeCoefficient * lampCoefficient) / 100;
}
import { AnyChartDocument, ChartDocument, ESDCore, integer } from "kamaitachi-common";
import { Logger } from "winston";
import db from "../../../../db/db";
import { DryScore } from "../../../../types";
/**
* Calculates the in-game CHUNITHM rating for a score.
*/
export function CalculateCHUNITHMRating(dryScore: DryScore, chartData: AnyChartDocument) {
let score = dryScore.scoreData.score;
let levelBase = chartData.levelNum * 100;
let val = 0;
if (score >= 1007500) {
val = levelBase + 200;
} else if (score >= 1005000) {
val = levelBase + 150 + ((score - 1005000) * 10) / 500;
} else if (score >= 1000000) {
val = levelBase + 100 + ((score - 1000000) * 5) / 500;
} else if (score >= 975000) {
val = levelBase + ((score - 975000) * 2) / 500;
} else if (score >= 925000) {
val = levelBase - 300 + ((score - 925000) * 3) / 500;
} else if (score >= 900000) {
val = levelBase - 500 + ((score - 900000) * 4) / 500;
} else if (score >= 800000) {
val = (levelBase - 500) / 2 + ((score - 800000) * ((levelBase - 500) / 2)) / 100000;
}
return Math.max(Math.floor(val) / 100, 0);
}
/**
* Calculates the in-game GITADORA rating for a score.
*/
export function CalculateGITADORARating(dryScore: DryScore, chartData: AnyChartDocument) {
let trueRating = (dryScore.scoreData.percent / 100) * chartData.levelNum * 20;
let flooredRating = Math.floor(trueRating * 100) / 100;
return flooredRating;
}
/**
* Calculates the PikaGreatFunction, used in BPI. I have no idea what this does.
* @returns
*/
function BPIPikaGreatFn(score: integer, max: integer) {
return score === max ? max * 0.8 : 1 + (score / max - 0.5) / (1 - score / max);
}
/**
* Oh boy.
*
* Calculates the "Beat Performance Index" of an IIDX score. This algorithm has many issues,
* but is a direct port of Poyashi's implementation for consistencies sake.
* https://github.com/potakusan/iidx_score_manager/blob/f21ba6b85fcc0bf8b7ca888fa2239a3951a9c9c2/src/components/bpi/index.tsx#L120
*
* @param kaidenEx The kaiden average EX score.
* @param wrEx The world record's EX score.
* @param yourEx Your EX score.
* @param max The maximum amount of EX achievable on this chart.
* @param powCoef What power the BPI should be raised to. This is arbitrary, and assigned on a per-song basis. Defaults to 1.175.
* @returns A number between -15 and 100. Unless your score is better than the world record, in which case
* returns can be above 100.
*/
export function CalculateBPI(
kaidenEx: integer,
wrEx: integer,
yourEx: integer,
max: integer,
powCoef: number | null
) {
powCoef ??= 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;
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
);
}
/**
* Calculates the percent of Kaidens you are ahead of with this score.
* @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) {
let scoreCount = await db["iidx-eam-scores"].count({
chartID: chartData.chartID,
});
if (!scoreCount) {
return null;
}
let worseScores = await db["iidx-eam-scores"].count({
chartID: chartData.chartID,
score: { $lt: scoreObj.scoreData.score },
});
return (100 * worseScores) / scoreCount;
}
/**
* An experimental statistic for determining how good a score is versus the Kaiden Average
* 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);
}
/**
* Calculate Marvelous Full Combo Points. This algorithm
* is used in LIFE4, and described here:
* https://life4ddr.com/requirements/#mfcpoints
* @returns Null if this score was not eligible, a number otherwise.
*/
export function CalculateMFCP(dryScore: DryScore, chartData: AnyChartDocument, logger: Logger) {
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;
}
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}.`
);
// 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,
};
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,
};
const VF5LampCoefficients = {
"PERFECT ULTIMATE CHAIN": 1.1,
"ULTIMATE CHAIN": 1.05,
"EXCESSIVE CLEAR": 1.02,
CLEAR: 1.0,
FAILED: 0.5,
};
export function CalculateVF4(
dryScore: DryScore<"sdvx:Single">,
chartData: AnyChartDocument,
logger: Logger
) {
const multiplier = 25;
let level = chartData.levelNum;
let gradeCoefficient = VF4GradeCoefficients[dryScore.scoreData.grade];
if (!gradeCoefficient) {
logger.warn(
`Invalid grade of ${dryScore.scoreData.grade} passed to CalculateVF4. Returning null.`
);
return null;
}
let percent = dryScore.scoreData.percent / 100;
if (!level || !percent) {
return 0;
}
return Math.floor(multiplier * (level + 1) * percent * gradeCoefficient);
}
// WARNING:
// this formula is allegedly tentative according to bemaniwiki.
// idk if it's right, but it must be close enough.
export function CalculateVF5(
dryScore: DryScore<"sdvx:Single">,
chartData: AnyChartDocument,
logger: Logger
) {
let level = chartData.levelNum;
let gradeCoefficient = VF5GradeCoefficients[dryScore.scoreData.grade];
let lampCoefficient = VF5LampCoefficients[dryScore.scoreData.lamp];
if (!lampCoefficient) {
logger.warn(
`Invalid lamp of ${dryScore.scoreData.lamp} passed to CalculateVF5. Returning null.`
);
return null;
}
if (!gradeCoefficient) {
logger.warn(
`Invalid grade of ${dryScore.scoreData.grade} passed to CalculateVF5. Returning null.`
);
return null;
}
let percent = dryScore.scoreData.percent / 100;
if (!level || !percent) {
return 0;
}
return Math.floor(level * 2 * percent * gradeCoefficient * lampCoefficient) / 100;
}
@@ -1,125 +1,125 @@
import Pr from "prudence";
import t from "tap";
import { CloseConnection } from "../../../../db/db";
import CreateLogCtx from "../../../../logger";
import prAssert from "../../../../test-utils/prassert";
import {
Testing511SPA,
TestingIIDXSPDryScore,
TestingSDVXSingleDryScore,
} from "../../../../test-utils/test-data";
import { CreateGameSpecific } from "./game-specific";
const logger = CreateLogCtx("fake-testing-context");
/**
* These tests only check that the right properties are assigned.
*/
t.test("#CreateGameSpecific", (t) => {
t.test("IIDX:SP", async (t) => {
let res = await CreateGameSpecific(
"iidx",
"SP",
Testing511SPA,
TestingIIDXSPDryScore,
logger
);
prAssert(
res,
{
BPI: "?number",
"K%": "?number",
KESDC: "?number",
},
"Response should contain keys for IIDX:SP GameSpecifics"
);
t.end();
});
t.test("IIDX:DP", async (t) => {
let res = await CreateGameSpecific(
"iidx",
"DP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an SP score. but we're testing
logger
);
prAssert(
res,
{
BPI: "?number",
KESDC: "?number",
},
"Response should contain keys for IIDX:DP GameSpecifics"
);
t.end();
});
t.test("SDVX:Single", async (t) => {
let res = await CreateGameSpecific(
"sdvx",
"Single",
Testing511SPA,
TestingSDVXSingleDryScore,
logger
);
prAssert(
res,
{
VF4: Pr.nullable(Pr.isPositiveInteger),
VF5: Pr.nullable(Pr.isPositive),
},
"Response should contain keys for SDVX:Single GameSpecifics"
);
t.end();
});
t.test("DDR:SP", async (t) => {
let res = await CreateGameSpecific(
"ddr",
"SP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
logger
);
prAssert(
res,
{
MFCP: Pr.nullable(Pr.isPositiveInteger),
},
"Response should contain keys for DDR:SP GameSpecifics"
);
t.end();
});
t.test("DDR:DP", async (t) => {
let res = await CreateGameSpecific(
"ddr",
"DP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
logger
);
prAssert(
res,
{
MFCP: "null",
},
"Response should contain nulled keys for DDR:DP GameSpecifics"
);
t.end();
});
t.end();
});
t.teardown(CloseConnection);
import Pr from "prudence";
import t from "tap";
import { CloseConnection } from "../../../../db/db";
import CreateLogCtx from "../../../../logger";
import prAssert from "../../../../test-utils/prassert";
import {
Testing511SPA,
TestingIIDXSPDryScore,
TestingSDVXSingleDryScore,
} from "../../../../test-utils/test-data";
import { CreateGameSpecific } from "./game-specific";
const logger = CreateLogCtx("fake-testing-context");
/**
* These tests only check that the right properties are assigned.
*/
t.test("#CreateGameSpecific", (t) => {
t.test("IIDX:SP", async (t) => {
let res = await CreateGameSpecific(
"iidx",
"SP",
Testing511SPA,
TestingIIDXSPDryScore,
logger
);
prAssert(
res,
{
BPI: "?number",
"K%": "?number",
KESDC: "?number",
},
"Response should contain keys for IIDX:SP GameSpecifics"
);
t.end();
});
t.test("IIDX:DP", async (t) => {
let res = await CreateGameSpecific(
"iidx",
"DP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an SP score. but we're testing
logger
);
prAssert(
res,
{
BPI: "?number",
KESDC: "?number",
},
"Response should contain keys for IIDX:DP GameSpecifics"
);
t.end();
});
t.test("SDVX:Single", async (t) => {
let res = await CreateGameSpecific(
"sdvx",
"Single",
Testing511SPA,
TestingSDVXSingleDryScore,
logger
);
prAssert(
res,
{
VF4: Pr.nullable(Pr.isPositiveInteger),
VF5: Pr.nullable(Pr.isPositive),
},
"Response should contain keys for SDVX:Single GameSpecifics"
);
t.end();
});
t.test("DDR:SP", async (t) => {
let res = await CreateGameSpecific(
"ddr",
"SP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
logger
);
prAssert(
res,
{
MFCP: Pr.nullable(Pr.isPositiveInteger),
},
"Response should contain keys for DDR:SP GameSpecifics"
);
t.end();
});
t.test("DDR:DP", async (t) => {
let res = await CreateGameSpecific(
"ddr",
"DP",
Testing511SPA,
TestingIIDXSPDryScore, // fake! this is an iidx score. but we're testing
logger
);
prAssert(
res,
{
MFCP: "null",
},
"Response should contain nulled keys for DDR:DP GameSpecifics"
);
t.end();
});
t.end();
});
t.teardown(CloseConnection);
@@ -1,59 +1,59 @@
import { AnyChartDocument, ChartDocument, Game, Playtypes } from "kamaitachi-common";
import { Logger } from "winston";
import db from "../../../../db/db";
import { DryScore } from "../../../../types";
import {
CalculateBPI,
CalculateKESDC,
CalculateMFCP,
CalculateVF4,
CalculateVF5,
KaidenPercentile,
} from "./game-specific-stats";
// Creates Game-Specific calculatedData for the provided game & playtype.
export async function CreateGameSpecific<G extends Game>(
game: G,
playtype: Playtypes[G],
chart: AnyChartDocument,
dryScore: DryScore,
logger: Logger
): Promise<Record<string, number | null>> {
let gameSpecific: Record<string, number | null> = {};
if (game === "iidx") {
let BPIData = await db["iidx-bpi-data"].findOne({
chartID: chart.chartID,
});
if (BPIData) {
gameSpecific.BPI = CalculateBPI(
BPIData.kavg,
BPIData.wr,
dryScore.scoreData.score,
(chart as ChartDocument<"iidx:DP" | "iidx:SP">).data.notecount * 2,
BPIData.coef
);
gameSpecific.KESDC =
dryScore.scoreData.esd === null
? null
: CalculateKESDC(BPIData.kesd, dryScore.scoreData.esd);
} else {
gameSpecific.BPI = null;
gameSpecific.KESDC = null;
}
if (playtype === "SP") {
gameSpecific["K%"] = await KaidenPercentile(dryScore, chart);
}
} else if (game === "sdvx") {
gameSpecific.VF4 = CalculateVF4(dryScore as DryScore<"sdvx:Single">, chart, logger);
gameSpecific.VF5 = CalculateVF5(dryScore as DryScore<"sdvx:Single">, chart, logger);
} else if (game === "ddr") {
// either playtype
gameSpecific.MFCP = CalculateMFCP(dryScore, chart, logger);
}
return gameSpecific;
}
import { AnyChartDocument, ChartDocument, Game, Playtypes } from "kamaitachi-common";
import { Logger } from "winston";
import db from "../../../../db/db";
import { DryScore } from "../../../../types";
import {
CalculateBPI,
CalculateKESDC,
CalculateMFCP,
CalculateVF4,
CalculateVF5,
KaidenPercentile,
} from "./game-specific-stats";
// Creates Game-Specific calculatedData for the provided game & playtype.
export async function CreateGameSpecific<G extends Game>(
game: G,
playtype: Playtypes[G],
chart: AnyChartDocument,
dryScore: DryScore,
logger: Logger
): Promise<Record<string, number | null>> {
let gameSpecific: Record<string, number | null> = {};
if (game === "iidx") {
let BPIData = await db["iidx-bpi-data"].findOne({
chartID: chart.chartID,
});
if (BPIData) {
gameSpecific.BPI = CalculateBPI(
BPIData.kavg,
BPIData.wr,
dryScore.scoreData.score,
(chart as ChartDocument<"iidx:DP" | "iidx:SP">).data.notecount * 2,
BPIData.coef
);
gameSpecific.KESDC =
dryScore.scoreData.esd === null
? null
: CalculateKESDC(BPIData.kesd, dryScore.scoreData.esd);
} else {
gameSpecific.BPI = null;
gameSpecific.KESDC = null;
}
if (playtype === "SP") {
gameSpecific["K%"] = await KaidenPercentile(dryScore, chart);
}
} else if (game === "sdvx") {
gameSpecific.VF4 = CalculateVF4(dryScore as DryScore<"sdvx:Single">, chart, logger);
gameSpecific.VF5 = CalculateVF5(dryScore as DryScore<"sdvx:Single">, chart, logger);
} else if (game === "ddr") {
// either playtype
gameSpecific.MFCP = CalculateMFCP(dryScore, chart, logger);
}
return gameSpecific;
}
@@ -1,60 +1,60 @@
// Various Errors that can occur during processing.
import { Converters } from "../../import-types/import-types";
export class ConverterFailure {
message: string | null;
constructor(message: string | null) {
this.message = message;
}
}
/**
* KTDataNotFoundError - We could not find the song or chart this score
* belongs to. The identifier used to try and match this chart is stored here.
* Alongside any other fields used in the query.
*/
export class KTDataNotFoundFailure<T extends keyof typeof Converters> extends ConverterFailure {
data: Parameters<typeof Converters[T]>[0];
converterContext: Parameters<typeof Converters[T]>[1];
importType: T;
constructor(
message: string | null,
importType: T,
data: Parameters<typeof Converters[T]>[0],
context: Parameters<typeof Converters[T]>[1]
) {
super(message);
this.importType = importType;
this.data = data;
this.converterContext = context;
}
}
/**
* ScoreExistsError - A score with this ID already exists.
* Since this error is mostly useless to the end user (people re-import scores all the time!)
* This is not returned from score processing, however, it exists so the reason for the error can be
* sussed out.
*/
export class ScoreExistsFailure extends ConverterFailure {
scoreID: string;
constructor(message: string | null, scoreID: string) {
super(message);
this.scoreID = scoreID;
}
}
/**
* InvalidScoreError - This score provided invalid data that Kamaitachi can not accept.
*/
export class InvalidScoreFailure extends ConverterFailure {}
/**
* KTInternalFailure - The score was valid, but Kamaitachi could not process it.
*/
export class InternalFailure extends ConverterFailure {}
// Various Errors that can occur during processing.
import { Converters } from "../../import-types/import-types";
export class ConverterFailure {
message: string | null;
constructor(message: string | null) {
this.message = message;
}
}
/**
* KTDataNotFoundError - We could not find the song or chart this score
* belongs to. The identifier used to try and match this chart is stored here.
* Alongside any other fields used in the query.
*/
export class KTDataNotFoundFailure<T extends keyof typeof Converters> extends ConverterFailure {
data: Parameters<typeof Converters[T]>[0];
converterContext: Parameters<typeof Converters[T]>[1];
importType: T;
constructor(
message: string | null,
importType: T,
data: Parameters<typeof Converters[T]>[0],
context: Parameters<typeof Converters[T]>[1]
) {
super(message);
this.importType = importType;
this.data = data;
this.converterContext = context;
}
}
/**
* ScoreExistsError - A score with this ID already exists.
* Since this error is mostly useless to the end user (people re-import scores all the time!)
* This is not returned from score processing, however, it exists so the reason for the error can be
* sussed out.
*/
export class ScoreExistsFailure extends ConverterFailure {
scoreID: string;
constructor(message: string | null, scoreID: string) {
super(message);
this.scoreID = scoreID;
}
}
/**
* InvalidScoreError - This score provided invalid data that Kamaitachi can not accept.
*/
export class InvalidScoreFailure extends ConverterFailure {}
/**
* KTInternalFailure - The score was valid, but Kamaitachi could not process it.
*/
export class InternalFailure extends ConverterFailure {}
@@ -1,61 +1,61 @@
import {
AnyChartDocument,
ChartDocument,
config,
integer,
ScoreDocument,
SongDocument,
} from "kamaitachi-common";
import { Logger } from "winston";
import { DryScore } from "../../../types";
import { CreateCalculatedData } from "./calculated-data/calculated-data";
/**
* Takes an "intermediate" score and appends the rest of the data it needs.
* @param dryScore The intermediate score to make into a real score.
* @param userID The userID this score is for.
*/
export default async function HydrateScore(
userID: integer,
dryScore: DryScore,
chart: AnyChartDocument,
song: SongDocument,
scoreID: string,
logger: Logger
): Promise<ScoreDocument> {
const calculatedData = await CreateCalculatedData(dryScore, chart, logger); // @todo
const { scoreData: dryScoreData, ...rest } = dryScore;
// Fill out the rest of the fields we want for scoreData
const scoreData = Object.assign(
{
// @todo 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.lamp as string),
},
dryScoreData
);
let 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,
difficulty: chart.difficulty,
playtype: chart.playtype,
// these will be sorted out in post-processing
isLampPB: false,
isScorePB: false,
};
return score;
}
import {
AnyChartDocument,
ChartDocument,
config,
integer,
ScoreDocument,
SongDocument,
} from "kamaitachi-common";
import { Logger } from "winston";
import { DryScore } from "../../../types";
import { CreateCalculatedData } from "./calculated-data/calculated-data";
/**
* Takes an "intermediate" score and appends the rest of the data it needs.
* @param dryScore The intermediate score to make into a real score.
* @param userID The userID this score is for.
*/
export default async function HydrateScore(
userID: integer,
dryScore: DryScore,
chart: AnyChartDocument,
song: SongDocument,
scoreID: string,
logger: Logger
): Promise<ScoreDocument> {
const calculatedData = await CreateCalculatedData(dryScore, chart, logger); // @todo
const { scoreData: dryScoreData, ...rest } = dryScore;
// Fill out the rest of the fields we want for scoreData
const scoreData = Object.assign(
{
// @todo 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.lamp as string),
},
dryScoreData
);
let 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,
difficulty: chart.difficulty,
playtype: chart.playtype,
// these will be sorted out in post-processing
isLampPB: false,
isScorePB: false,
};
return score;
}
@@ -1,8 +1,8 @@
import { ImportTypes, PublicUserDocument } from "kamaitachi-common";
import { CreateScoreLogger } from "../../../logger";
import crypto from "crypto";
export function CreateImportLoggerAndID(userDoc: PublicUserDocument, importType: ImportTypes) {
const importID = crypto.randomBytes(20).toString("hex");
return { logger: CreateScoreLogger(userDoc, importID, importType), importID };
}
import { ImportTypes, PublicUserDocument } from "kamaitachi-common";
import { CreateScoreLogger } from "../../../logger";
import crypto from "crypto";
export function CreateImportLoggerAndID(userDoc: PublicUserDocument, importType: ImportTypes) {
const importID = crypto.randomBytes(20).toString("hex");
return { logger: CreateScoreLogger(userDoc, importID, importType), importID };
}
@@ -1,84 +1,84 @@
import { ScoreDocument } from "kamaitachi-common";
import t from "tap";
import db, { CloseConnection } from "../../../db/db";
import ResetDBState from "../../../test-utils/reset-db-state";
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);
// empty scoreDB after
t.afterEach(async () => {
await db.scores.remove({});
});
t.test("Single Queue Test", async (t) => {
// fake score doc
let res = await QueueScoreInsert(({ testDocument: "foo" } as unknown) as ScoreDocument);
t.equal(
res,
null,
"QueueScoreInsert should not insert a score when the queue is not full."
);
// this is the best way to get the size of the queue
let flushSize = await InsertQueue();
t.equal(flushSize, 1, "QueueScoreInsert should append the score to the queue.");
let dbRes = await db.scores.find({
testDocument: "foo",
});
t.equal(
dbRes.length,
1,
"InsertQueue should insert all 1 members of the queue into the database."
);
t.end();
});
let r = await InsertQueue(); // flush queue just incase former test fails.
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(({ testDocument: "foo" } as unknown) as ScoreDocument);
}
let overflowRes = await QueueScoreInsert(({
testDocument: "foo",
} as unknown) as ScoreDocument);
t.equal(
overflowRes,
500,
"Appending 500 items to the queue should result in them being inserted."
);
let flushRes = await InsertQueue();
t.equal(flushRes, 0, "The queue should now be empty.");
let dbRes = await db.scores.find({
testDocument: "foo",
});
t.equal(
dbRes.length,
500,
"InsertQueue should insert all 500 members of the queue into the database."
);
t.end();
});
t.end();
});
t.teardown(CloseConnection);
import { ScoreDocument } from "kamaitachi-common";
import t from "tap";
import db, { CloseConnection } from "../../../db/db";
import ResetDBState from "../../../test-utils/reset-db-state";
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);
// empty scoreDB after
t.afterEach(async () => {
await db.scores.remove({});
});
t.test("Single Queue Test", async (t) => {
// fake score doc
let res = await QueueScoreInsert(({ testDocument: "foo" } as unknown) as ScoreDocument);
t.equal(
res,
null,
"QueueScoreInsert should not insert a score when the queue is not full."
);
// this is the best way to get the size of the queue
let flushSize = await InsertQueue();
t.equal(flushSize, 1, "QueueScoreInsert should append the score to the queue.");
let dbRes = await db.scores.find({
testDocument: "foo",
});
t.equal(
dbRes.length,
1,
"InsertQueue should insert all 1 members of the queue into the database."
);
t.end();
});
let r = await InsertQueue(); // flush queue just incase former test fails.
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(({ testDocument: "foo" } as unknown) as ScoreDocument);
}
let overflowRes = await QueueScoreInsert(({
testDocument: "foo",
} as unknown) as ScoreDocument);
t.equal(
overflowRes,
500,
"Appending 500 items to the queue should result in them being inserted."
);
let flushRes = await InsertQueue();
t.equal(flushRes, 0, "The queue should now be empty.");
let dbRes = await db.scores.find({
testDocument: "foo",
});
t.equal(
dbRes.length,
500,
"InsertQueue should insert all 500 members of the queue into the database."
);
t.end();
});
t.end();
});
t.teardown(CloseConnection);
@@ -1,32 +1,32 @@
import { ScoreDocument } from "kamaitachi-common";
import db from "../../../db/db";
let ScoreQueue: ScoreDocument[] = [];
const MAX_PIPELINE_LENGTH = 500;
/**
* Adds a score to a queue to be inserted in batch to the database.
* @param score - The score document to queue.
*/
export async function QueueScoreInsert(score: ScoreDocument) {
ScoreQueue.push(score);
if (ScoreQueue.length >= MAX_PIPELINE_LENGTH) {
return await InsertQueue();
}
return null;
}
/**
* Bulk inserts the entire Queue.
* @warn Be cautious of inducing race conditions when using this function.
*/
export async function InsertQueue() {
const temp = ScoreQueue.splice(0);
if (temp.length !== 0) {
await db.scores.insert(temp);
}
return temp.length;
}
import { ScoreDocument } from "kamaitachi-common";
import db from "../../../db/db";
let ScoreQueue: ScoreDocument[] = [];
const MAX_PIPELINE_LENGTH = 500;
/**
* Adds a score to a queue to be inserted in batch to the database.
* @param score - The score document to queue.
*/
export async function QueueScoreInsert(score: ScoreDocument) {
ScoreQueue.push(score);
if (ScoreQueue.length >= MAX_PIPELINE_LENGTH) {
return await InsertQueue();
}
return null;
}
/**
* Bulk inserts the entire Queue.
* @warn Be cautious of inducing race conditions when using this function.
*/
export async function InsertQueue() {
const temp = ScoreQueue.splice(0);
if (temp.length !== 0) {
await db.scores.insert(temp);
}
return temp.length;
}
@@ -1,60 +1,60 @@
import { ScoreDocument } from "kamaitachi-common";
import t from "tap";
import db, { CloseConnection } from "../../../db/db";
import ResetDBState from "../../../test-utils/reset-db-state";
import { Testing511SPA, TestingIIDXSPDryScore } from "../../../test-utils/test-data";
import { CreateScoreID, GetWithScoreID } from "./score-id";
t.beforeEach(ResetDBState);
t.test("#GetWithScoreID", async (t) => {
let sc = await GetWithScoreID("TESTING_SCORE_ID");
t.not(sc, null, "Return a score for a valid score id");
let nl = await GetWithScoreID("INVALID SCORE ID");
t.equal(nl, null, "Should return null for score IDs it cannot resolve");
t.end();
});
t.test("#CreateScoreID", (t) => {
let scoreID = CreateScoreID(1, TestingIIDXSPDryScore, Testing511SPA.chartID);
t.match(
scoreID,
/^R[0-9a-f]{40}/,
"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.equal(
scoreID,
CreateScoreID(1, TestingIIDXSPDryScore, Testing511SPA.chartID),
"ScoreIDs should be consistently generatable"
);
// duplicate score
let minimalScore = {
scoreData: {
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, grade and lamp."
);
t.end();
});
t.teardown(CloseConnection);
import { ScoreDocument } from "kamaitachi-common";
import t from "tap";
import db, { CloseConnection } from "../../../db/db";
import ResetDBState from "../../../test-utils/reset-db-state";
import { Testing511SPA, TestingIIDXSPDryScore } from "../../../test-utils/test-data";
import { CreateScoreID, GetWithScoreID } from "./score-id";
t.beforeEach(ResetDBState);
t.test("#GetWithScoreID", async (t) => {
let sc = await GetWithScoreID("TESTING_SCORE_ID");
t.not(sc, null, "Return a score for a valid score id");
let nl = await GetWithScoreID("INVALID SCORE ID");
t.equal(nl, null, "Should return null for score IDs it cannot resolve");
t.end();
});
t.test("#CreateScoreID", (t) => {
let scoreID = CreateScoreID(1, TestingIIDXSPDryScore, Testing511SPA.chartID);
t.match(
scoreID,
/^R[0-9a-f]{40}/,
"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.equal(
scoreID,
CreateScoreID(1, TestingIIDXSPDryScore, Testing511SPA.chartID),
"ScoreIDs should be consistently generatable"
);
// duplicate score
let minimalScore = {
scoreData: {
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, grade and lamp."
);
t.end();
});
t.teardown(CloseConnection);
@@ -1,35 +1,35 @@
import { integer, DryScore } from "../../../types";
import crypto from "crypto";
import db from "../../../db/db";
function CreateScoreIDString(userID: integer, partialScore: DryScore, chartID: string) {
const { lamp, grade } = partialScore.scoreData;
return `${userID}|${chartID}|${lamp}|${grade}`;
}
/**
* Performs sha256 hashing on the input data.
* @param scoreIDString - The string to sha256 hash.
* @returns A sha256 checksum in lowercase hex.
*/
function HashScoreIDString(scoreIDString: string) {
return crypto.createHash("sha256").update(scoreIDString).digest("hex");
}
/**
* Creates an identifier for this score.
* This is used to deduplicate repeated scores.
* @returns @see HashScoreIDString - prefixed with R.
*/
export function CreateScoreID(userID: integer, dryScore: DryScore, chartID: string) {
const scoreIDString = CreateScoreIDString(userID, dryScore, chartID);
return `R${HashScoreIDString(scoreIDString)}`;
}
export function GetWithScoreID(scoreID: string) {
return db.scores.findOne({
scoreID,
});
}
import { integer, DryScore } from "../../../types";
import crypto from "crypto";
import db from "../../../db/db";
function CreateScoreIDString(userID: integer, partialScore: DryScore, chartID: string) {
const { lamp, grade } = partialScore.scoreData;
return `${userID}|${chartID}|${lamp}|${grade}`;
}
/**
* Performs sha256 hashing on the input data.
* @param scoreIDString - The string to sha256 hash.
* @returns A sha256 checksum in lowercase hex.
*/
function HashScoreIDString(scoreIDString: string) {
return crypto.createHash("sha256").update(scoreIDString).digest("hex");
}
/**
* Creates an identifier for this score.
* This is used to deduplicate repeated scores.
* @returns @see HashScoreIDString - prefixed with R.
*/
export function CreateScoreID(userID: integer, dryScore: DryScore, chartID: string) {
const scoreIDString = CreateScoreIDString(userID, dryScore, chartID);
return `R${HashScoreIDString(scoreIDString)}`;
}
export function GetWithScoreID(scoreID: string) {
return db.scores.findOne({
scoreID,
});
}
@@ -1,11 +1,11 @@
import t from "tap";
import ScoreImportFatalError from "./score-import-error";
t.test("#new ScoreImportFatalError()", (t) => {
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.end();
});
import t from "tap";
import ScoreImportFatalError from "./score-import-error";
t.test("#new ScoreImportFatalError()", (t) => {
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.end();
});
@@ -1,17 +1,17 @@
/**
* Helper utility to exit deeply nested routes
*/
/**
* Throw this if the ScoreImport is now completely screwed,
* and needs to exit (completely!).
*/
export default class ScoreImportFatalError extends Error {
statusCode: number;
constructor(statusCode: number, description: string) {
super(description);
this.statusCode = statusCode;
}
}
/**
* Helper utility to exit deeply nested routes
*/
/**
* Throw this if the ScoreImport is now completely screwed,
* and needs to exit (completely!).
*/
export default class ScoreImportFatalError extends Error {
statusCode: number;
constructor(statusCode: number, description: string) {
super(description);
this.statusCode = statusCode;
}
}
@@ -1,23 +1,23 @@
import { config, Game, Grades, IDStrings } from "kamaitachi-common";
/**
* Util for getting a games' grade for a given percent.
*/
export function GetGradeFromPercent<I extends IDStrings = IDStrings>(
game: Game,
percent: number
): Grades[I] | null {
// @todo 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!)
// eslint-disable-next-line for-direction
for (let i = boundaries.length; i >= 0; i++) {
if (percent > boundaries[i]) {
return grades[i] as Grades[I];
}
}
return null;
}
import { config, Game, Grades, IDStrings } from "kamaitachi-common";
/**
* Util for getting a games' grade for a given percent.
*/
export function GetGradeFromPercent<I extends IDStrings = IDStrings>(
game: Game,
percent: number
): Grades[I] | null {
// @todo 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!)
// eslint-disable-next-line for-direction
for (let i = boundaries.length; i >= 0; i++) {
if (percent > boundaries[i]) {
return grades[i] as Grades[I];
}
}
return null;
}
@@ -1,226 +1,226 @@
import {
AnyChartDocument,
ChartDocument,
ImportProcessingInfo,
ScoreDocument,
SongDocument,
} from "kamaitachi-common";
import { integer, DryScore, ConverterFunction, ConverterFnReturn } from "../../../types";
import HydrateScore from "../core/hydrate-score";
import { QueueScoreInsert } from "../core/insert-score";
import {
ConverterFailure,
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "../core/converter-errors";
import { CreateScoreID } from "../core/score-id";
import db from "../../../db/db";
import { Logger } from "winston";
import { AppendLogCtx } from "../../../logger";
/**
* Processes the iterable data into the Kamaitachi database.
* @param userID - The user this score import was for.
* @param iterableData - The data to iterate upon.
* @param ConverterFunction - The function needed to convert the data into an IntermediateScore
* @param context - Any context the Converter may need in order to make decisions.
* @returns An array of ImportProcessInfo objects.
*/
export async function ImportAllIterableData<D, C>(
userID: integer,
iterableData: Iterable<D> | AsyncIterable<D>,
ConverterFunction: ConverterFunction<D, C>,
context: C,
logger: Logger
): Promise<ImportProcessingInfo[]> {
logger.verbose(`Starting Data Processing...`);
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, data, ConverterFunction, context, logger));
}
// Due to the fact that ProcessIterableDatapoint may return an array instead of a single result
// (e-amusement is the only real example of this);
// we need to flatten out the datapoints into a single array. We also use this time
// to filter out nulls, which we don't care for (these are neither successes or failures)
let nonFlatDatapoints = await Promise.all(promises);
logger.verbose(`Finished Importing Data (${promises.length} datapoints).`);
logger.verbose(`Flattening returns...`);
let flatDatapoints = [];
for (const dp of nonFlatDatapoints) {
if (dp === null) {
continue;
}
if (Array.isArray(dp)) {
for (const dpx of dp) {
if (dpx === null) {
continue;
}
flatDatapoints.push(dpx);
}
} else {
flatDatapoints.push(dp);
}
}
logger.verbose(`Flattened returns.`);
logger.verbose(`Recieved ${flatDatapoints.length} returns, from ${promises.length} data.`);
return flatDatapoints;
}
/**
* Processes a single data object into one or many ImportProcessingInfo objects.
* @param userID - The user this score is from.
* @param data - The data to process.
* @param ConverterFunction - The processor function that takes the data and returns the partialScore(s)
* @param context - Any context the processor might need that it can not infer from the data object.
* @returns An array of ImportProcessingInfo objects, or a single ImportProcessingInfo object
*/
export async function ImportIterableDatapoint<D, C>(
userID: integer,
data: D,
ConverterFunction: ConverterFunction<D, C>,
context: C,
logger: Logger
) {
const converterReturns = await ConverterFunction(data, context, logger);
if (Array.isArray(converterReturns)) {
return Promise.all(
converterReturns.map((e) => ImportFromConverterReturn(userID, e, logger))
);
}
return ImportFromConverterReturn(userID, converterReturns, logger);
}
/**
* Hydrates and inserts a score to the Kamaitachi database.
* @param userID - The user this score is from.
* @param dryScore - The score that is to be hydrated and inserted.
* @param chart - The chart this score is on.
* @param song - The song this score is on.
* @returns no idea yet
*/
async function HydrateAndInsertScore(
userID: integer,
dryScore: DryScore,
chart: AnyChartDocument,
song: SongDocument,
importLogger: Logger
): Promise<ScoreDocument | null> {
const scoreID = CreateScoreID(userID, dryScore, chart.chartID);
// 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,
},
}
);
if (existingScore) {
logger.debug(`Skipped existing score ${scoreID}.`);
return null;
}
const score = await HydrateScore(userID, dryScore, chart, song, scoreID, logger);
await QueueScoreInsert(score);
return score;
}
async function ImportFromConverterReturn(
userID: integer,
cfnReturn: ConverterFnReturn, // a single return, not an array!
logger: Logger
): Promise<ImportProcessingInfo | null> {
// null => processing didnt result in a score document, but not an error, no processing needed!
if (cfnReturn === null) {
return null;
}
// if this conversion failed, return it in the proper format
if (cfnReturn instanceof ConverterFailure) {
if (cfnReturn instanceof KTDataNotFoundFailure) {
return {
success: false,
type: "KTDataNotFound",
message: cfnReturn.message,
content: {
context: cfnReturn.converterContext,
data: cfnReturn.data,
},
};
} else if (cfnReturn instanceof InvalidScoreFailure) {
return {
success: false,
type: "InvalidDatapoint",
message: cfnReturn.message,
content: {},
};
} else if (cfnReturn instanceof InternalFailure) {
return {
success: false,
type: "InternalError",
message: cfnReturn.message,
content: {},
};
} else {
logger.warn(`Unknown error returned as ConverterFailure, Ignoring.`, {
err: cfnReturn,
});
return null; // lets not crash the entire import, but, this is weird.
}
}
let 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;
}
return {
success: true,
type: "ScoreImported",
message: null,
content: {
score: result,
},
};
}
import {
AnyChartDocument,
ChartDocument,
ImportProcessingInfo,
ScoreDocument,
SongDocument,
} from "kamaitachi-common";
import { integer, DryScore, ConverterFunction, ConverterFnReturn } from "../../../types";
import HydrateScore from "../core/hydrate-score";
import { QueueScoreInsert } from "../core/insert-score";
import {
ConverterFailure,
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "../core/converter-errors";
import { CreateScoreID } from "../core/score-id";
import db from "../../../db/db";
import { Logger } from "winston";
import { AppendLogCtx } from "../../../logger";
/**
* Processes the iterable data into the Kamaitachi database.
* @param userID - The user this score import was for.
* @param iterableData - The data to iterate upon.
* @param ConverterFunction - The function needed to convert the data into an IntermediateScore
* @param context - Any context the Converter may need in order to make decisions.
* @returns An array of ImportProcessInfo objects.
*/
export async function ImportAllIterableData<D, C>(
userID: integer,
iterableData: Iterable<D> | AsyncIterable<D>,
ConverterFunction: ConverterFunction<D, C>,
context: C,
logger: Logger
): Promise<ImportProcessingInfo[]> {
logger.verbose(`Starting Data Processing...`);
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, data, ConverterFunction, context, logger));
}
// Due to the fact that ProcessIterableDatapoint may return an array instead of a single result
// (e-amusement is the only real example of this);
// we need to flatten out the datapoints into a single array. We also use this time
// to filter out nulls, which we don't care for (these are neither successes or failures)
let nonFlatDatapoints = await Promise.all(promises);
logger.verbose(`Finished Importing Data (${promises.length} datapoints).`);
logger.verbose(`Flattening returns...`);
let flatDatapoints = [];
for (const dp of nonFlatDatapoints) {
if (dp === null) {
continue;
}
if (Array.isArray(dp)) {
for (const dpx of dp) {
if (dpx === null) {
continue;
}
flatDatapoints.push(dpx);
}
} else {
flatDatapoints.push(dp);
}
}
logger.verbose(`Flattened returns.`);
logger.verbose(`Recieved ${flatDatapoints.length} returns, from ${promises.length} data.`);
return flatDatapoints;
}
/**
* Processes a single data object into one or many ImportProcessingInfo objects.
* @param userID - The user this score is from.
* @param data - The data to process.
* @param ConverterFunction - The processor function that takes the data and returns the partialScore(s)
* @param context - Any context the processor might need that it can not infer from the data object.
* @returns An array of ImportProcessingInfo objects, or a single ImportProcessingInfo object
*/
export async function ImportIterableDatapoint<D, C>(
userID: integer,
data: D,
ConverterFunction: ConverterFunction<D, C>,
context: C,
logger: Logger
) {
const converterReturns = await ConverterFunction(data, context, logger);
if (Array.isArray(converterReturns)) {
return Promise.all(
converterReturns.map((e) => ImportFromConverterReturn(userID, e, logger))
);
}
return ImportFromConverterReturn(userID, converterReturns, logger);
}
/**
* Hydrates and inserts a score to the Kamaitachi database.
* @param userID - The user this score is from.
* @param dryScore - The score that is to be hydrated and inserted.
* @param chart - The chart this score is on.
* @param song - The song this score is on.
* @returns no idea yet
*/
async function HydrateAndInsertScore(
userID: integer,
dryScore: DryScore,
chart: AnyChartDocument,
song: SongDocument,
importLogger: Logger
): Promise<ScoreDocument | null> {
const scoreID = CreateScoreID(userID, dryScore, chart.chartID);
// 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,
},
}
);
if (existingScore) {
logger.debug(`Skipped existing score ${scoreID}.`);
return null;
}
const score = await HydrateScore(userID, dryScore, chart, song, scoreID, logger);
await QueueScoreInsert(score);
return score;
}
async function ImportFromConverterReturn(
userID: integer,
cfnReturn: ConverterFnReturn, // a single return, not an array!
logger: Logger
): Promise<ImportProcessingInfo | null> {
// null => processing didnt result in a score document, but not an error, no processing needed!
if (cfnReturn === null) {
return null;
}
// if this conversion failed, return it in the proper format
if (cfnReturn instanceof ConverterFailure) {
if (cfnReturn instanceof KTDataNotFoundFailure) {
return {
success: false,
type: "KTDataNotFound",
message: cfnReturn.message,
content: {
context: cfnReturn.converterContext,
data: cfnReturn.data,
},
};
} else if (cfnReturn instanceof InvalidScoreFailure) {
return {
success: false,
type: "InvalidDatapoint",
message: cfnReturn.message,
content: {},
};
} else if (cfnReturn instanceof InternalFailure) {
return {
success: false,
type: "InternalError",
message: cfnReturn.message,
content: {},
};
} else {
logger.warn(`Unknown error returned as ConverterFailure, Ignoring.`, {
err: cfnReturn,
});
return null; // lets not crash the entire import, but, this is weird.
}
}
let 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;
}
return {
success: true,
type: "ScoreImported",
message: null,
content: {
score: result,
},
};
}
@@ -1,54 +1,54 @@
import { ImportTypes, KTBlackImportDocument, PublicUserDocument } from "kamaitachi-common";
import { ImportInputParser } from "../../types";
import { InsertQueue } from "./core/insert-score";
import { ImportAllIterableData } from "./importing/score-importing";
import { CreateImportLoggerAndID } from "./core/import-logger";
export default async function ScoreImportMain<D, C>(
user: PublicUserDocument,
importType: ImportTypes,
InputParser: ImportInputParser<D, C>
) {
const timeStarted = Date.now();
const { importID, logger } = CreateImportLoggerAndID(user, importType);
const { iterable, ConverterFunction, context, idStrings } = await InputParser(logger);
// @todo: scope logger properly
logger.verbose("Received import request.");
let importInfo = await ImportAllIterableData(
user.id,
iterable,
ConverterFunction,
context,
logger
);
// Empty anything in the score queue
let emptied = await InsertQueue();
logger.verbose(`Emptied ${emptied} documents from score queue`);
// Update user's rating information
// @todo
// Construct sessions from successful scores
// @todo
// Create import document
const ImportDocument: KTBlackImportDocument = {
importType,
idStrings: idStrings,
importInfo,
importID,
timeFinished: Date.now(),
timeStarted,
createdSessions: [], // @todo, see session ctor
userID: user.id,
};
// Add this to the imports database
// @todo
return ImportDocument;
}
import { ImportTypes, KTBlackImportDocument, PublicUserDocument } from "kamaitachi-common";
import { ImportInputParser } from "../../types";
import { InsertQueue } from "./core/insert-score";
import { ImportAllIterableData } from "./importing/score-importing";
import { CreateImportLoggerAndID } from "./core/import-logger";
export default async function ScoreImportMain<D, C>(
user: PublicUserDocument,
importType: ImportTypes,
InputParser: ImportInputParser<D, C>
) {
const timeStarted = Date.now();
const { importID, logger } = CreateImportLoggerAndID(user, importType);
const { iterable, ConverterFunction, context, idStrings } = await InputParser(logger);
// @todo: scope logger properly
logger.verbose("Received import request.");
let importInfo = await ImportAllIterableData(
user.id,
iterable,
ConverterFunction,
context,
logger
);
// Empty anything in the score queue
let emptied = await InsertQueue();
logger.verbose(`Emptied ${emptied} documents from score queue`);
// Update user's rating information
// @todo
// Construct sessions from successful scores
// @todo
// Create import document
const ImportDocument: KTBlackImportDocument = {
importType,
idStrings: idStrings,
importInfo,
importID,
timeFinished: Date.now(),
timeStarted,
createdSessions: [], // @todo, see session ctor
userID: user.id,
};
// Add this to the imports database
// @todo
return ImportDocument;
}
@@ -1,218 +1,218 @@
import { config, ESDCore, Lamps, SongDocument } from "kamaitachi-common";
import { Logger } from "winston";
import { DryScore, ConverterFunction, ConverterFnReturn } from "../../../types";
import { FindChartWithPTDF } from "../../database-lookup/chart-ptdf";
import { FindSongOnTitleVersion } from "../../database-lookup/song-title";
import {
KTDataNotFoundFailure,
InternalFailure,
InvalidScoreFailure,
} from "../../framework/core/converter-errors";
import ScoreImportFatalError from "../../framework/core/score-import-error";
import { GetGradeFromPercent } from "../../framework/core/score-utils";
import { EamusementScoreData, IIDXEamusementCSVContext, IIDXEamusementCSVData } from "./types";
export interface DataTest {
foo: string;
}
const EAMUSEMENT_LAMP_RESOLVER: Map<string, Lamps["iidx:SP" | "iidx:DP"]> = new Map([
["NO PLAY", "NO PLAY"],
["FULLCOMBO CLEAR", "FULL COMBO"],
["EX HARD CLEAR", "EX HARD CLEAR"],
["HARD CLEAR", "HARD CLEAR"],
["CLEAR", "CLEAR"],
["EASY CLEAR", "EASY CLEAR"],
["ASSIST CLEAR", "ASSIST CLEAR"],
]);
async function EamScoreConverter(
eamScore: EamusementScoreData,
ktchiSong: SongDocument,
context: IIDXEamusementCSVContext,
data: IIDXEamusementCSVData,
logger: Logger
) {
const HUMANISED_SONG_TITLE = `${ktchiSong.title} (${context.playtype} ${eamScore.difficulty})`;
if (!eamScore.level) {
// charts that dont exist in the format have a level of 0
return null;
}
if (eamScore.exscore === 0) {
// skip scores with an exscore of 0
// This also skips things like score resets.
return null;
}
let ktchiChart = await FindChartWithPTDF(
"iidx",
ktchiSong.id,
context.playtype,
eamScore.difficulty
);
if (!ktchiChart) {
throw new KTDataNotFoundFailure(
`${HUMANISED_SONG_TITLE}`,
"csv:eamusement-iidx",
data,
context
);
}
if (typeof eamScore.exscore !== "number") {
throw new InvalidScoreFailure(
`${HUMANISED_SONG_TITLE} - Invalid EX Score of ${eamScore.exscore} (Was not a number?).`
);
} else if (!Number.isSafeInteger(eamScore.exscore)) {
throw new InvalidScoreFailure(
`${HUMANISED_SONG_TITLE} - Invalid EX Score of ${eamScore.exscore} (Not an integer?).`
);
} else if (eamScore.exscore < 0) {
throw new InvalidScoreFailure(
`${HUMANISED_SONG_TITLE} - Invalid EX Score of ${eamScore.exscore} (Not an integer?).`
);
} else if (eamScore.exscore > ktchiChart.notedata.notecount) {
throw new InvalidScoreFailure(
`${HUMANISED_SONG_TITLE} - Invalid EX Score of ${eamScore.exscore} (Was greater than chart notecount of ${ktchiChart.notedata.notecount}).`
);
}
const lamp = EAMUSEMENT_LAMP_RESOLVER.get(eamScore.lamp);
if (!lamp) {
logger.error(`Invalid lamp of ${eamScore.lamp} provided.`);
throw new InvalidScoreFailure(
`${HUMANISED_SONG_TITLE} - Invalid Lamp of ${eamScore.lamp}.`
);
}
const percent = (100 * eamScore.exscore) / ktchiChart.notedata.notecount;
const grade = GetGradeFromPercent<"iidx:SP" | "iidx:DP">("iidx", percent);
if (!grade) {
throw new InternalFailure(
`${HUMANISED_SONG_TITLE} - Could not resolve ${percent} into a grade?`
);
}
let esd = 200;
// esd cannot estimate things below this level of accuracy, so only actually calculate it here
if (percent > 0.1) {
esd = ESDCore.CalculateESD(config.judgementWindows.iidx[context.playtype], percent);
}
// Now we need to figure out the timestamp for this score.
// Under, well, normal circumstances, we could figure this out quite trivially
// But e-amusement provides us the timestamp for the *song*, not the score
//
// We're going to actually ignore this. Initial drafts of this assumed we could skip the
// "epochs" (when a score reset on eamusement happened), but it turns out those are
// generated when the user triggers a score migration, NOT (as initially thought) when
// KONAMI decides.
let timestamp = Date.parse(data.timestamp);
let dryScore: DryScore<"iidx:SP" | "iidx:DP"> = {
service: context.serviceOrigin,
comment: null,
game: "iidx",
importType: "csv:eamusement-iidx",
scoreData: {
score: eamScore.exscore,
lamp,
hitData: {
pgreat: eamScore.pgreat,
great: eamScore.great,
},
hitMeta: {},
percent,
grade,
esd,
},
scoreMeta: {},
timeAchieved: timestamp,
};
if (typeof eamScore.bp === "number") {
if (!Number.isInteger(eamScore.bp)) {
throw new InvalidScoreFailure(`${HUMANISED_SONG_TITLE} - Invalid BP of ${eamScore.bp}`);
}
dryScore.scoreData.hitMeta.bp = eamScore.bp;
} else if (eamScore.bp === "---") {
logger.verbose(
`Skipped assigning BP for score as it had expected null value of ${eamScore.bp}.`
);
} else {
logger.warn(`Skipped assigning BP for score. Had unexpected value of ${eamScore.bp}.`);
}
return { ktchiChart, dryScore };
}
/**
* Thin wrapper around EamScoreConverter to ensure it returns the expected values
* @param eamScore - The eamusement score data to convert.
* @param song - The song this score is for.
* @param context - Context the converter may need.
* @param data - The parent data the eamScore derives from.
* @returns ConverterFnReturn
*/
async function EamScoreConverterWrapper(
eamScore: EamusementScoreData,
song: SongDocument,
context: IIDXEamusementCSVContext,
data: IIDXEamusementCSVData,
logger: Logger
) {
try {
let results = await EamScoreConverter(eamScore, song!, context, data, logger);
if (!results) {
return null;
}
return {
song,
chart: results.ktchiChart,
dryScore: results.dryScore,
};
} catch (err) {
if (
err instanceof KTDataNotFoundFailure ||
err instanceof InternalFailure ||
err instanceof InvalidScoreFailure
) {
return err;
} else if (err instanceof ScoreImportFatalError) {
throw err; // throw it all the way up.
} else {
logger.error(`Unknown error: `, { err });
return new InternalFailure("An unknown internal failure has occured.");
}
}
}
const ConverterFn: ConverterFunction<IIDXEamusementCSVData, IIDXEamusementCSVContext> = async (
data,
context,
logger: Logger
): Promise<ConverterFnReturn[]> => {
let ktchiSong = await FindSongOnTitleVersion("iidx", data.title, context.importVersion);
if (!ktchiSong) {
logger.warn(`Could not find song for ${data.title}.`);
throw new KTDataNotFoundFailure(data.title, "csv:eamusement-iidx", data, context);
}
// ts thinks ktchiSong might be null. It's not, though!
let results = await Promise.all(
data.scores.map((e) => EamScoreConverterWrapper(e, ktchiSong!, context, data, logger))
);
return results;
};
export default ConverterFn;
import { config, ESDCore, Lamps, SongDocument } from "kamaitachi-common";
import { Logger } from "winston";
import { DryScore, ConverterFunction, ConverterFnReturn } from "../../../types";
import { FindChartWithPTDF } from "../../database-lookup/chart-ptdf";
import { FindSongOnTitleVersion } from "../../database-lookup/song-title";
import {
KTDataNotFoundFailure,
InternalFailure,
InvalidScoreFailure,
} from "../../framework/core/converter-errors";
import ScoreImportFatalError from "../../framework/core/score-import-error";
import { GetGradeFromPercent } from "../../framework/core/score-utils";
import { EamusementScoreData, IIDXEamusementCSVContext, IIDXEamusementCSVData } from "./types";
export interface DataTest {
foo: string;
}
const EAMUSEMENT_LAMP_RESOLVER: Map<string, Lamps["iidx:SP" | "iidx:DP"]> = new Map([
["NO PLAY", "NO PLAY"],
["FULLCOMBO CLEAR", "FULL COMBO"],
["EX HARD CLEAR", "EX HARD CLEAR"],
["HARD CLEAR", "HARD CLEAR"],
["CLEAR", "CLEAR"],
["EASY CLEAR", "EASY CLEAR"],
["ASSIST CLEAR", "ASSIST CLEAR"],
]);
async function EamScoreConverter(
eamScore: EamusementScoreData,
ktchiSong: SongDocument,
context: IIDXEamusementCSVContext,
data: IIDXEamusementCSVData,
logger: Logger
) {
const HUMANISED_SONG_TITLE = `${ktchiSong.title} (${context.playtype} ${eamScore.difficulty})`;
if (!eamScore.level) {
// charts that dont exist in the format have a level of 0
return null;
}
if (eamScore.exscore === 0) {
// skip scores with an exscore of 0
// This also skips things like score resets.
return null;
}
let ktchiChart = await FindChartWithPTDF(
"iidx",
ktchiSong.id,
context.playtype,
eamScore.difficulty
);
if (!ktchiChart) {
throw new KTDataNotFoundFailure(
`${HUMANISED_SONG_TITLE}`,
"csv:eamusement-iidx",
data,
context
);
}
if (typeof eamScore.exscore !== "number") {
throw new InvalidScoreFailure(
`${HUMANISED_SONG_TITLE} - Invalid EX Score of ${eamScore.exscore} (Was not a number?).`
);
} else if (!Number.isSafeInteger(eamScore.exscore)) {
throw new InvalidScoreFailure(
`${HUMANISED_SONG_TITLE} - Invalid EX Score of ${eamScore.exscore} (Not an integer?).`
);
} else if (eamScore.exscore < 0) {
throw new InvalidScoreFailure(
`${HUMANISED_SONG_TITLE} - Invalid EX Score of ${eamScore.exscore} (Not an integer?).`
);
} else if (eamScore.exscore > ktchiChart.notedata.notecount) {
throw new InvalidScoreFailure(
`${HUMANISED_SONG_TITLE} - Invalid EX Score of ${eamScore.exscore} (Was greater than chart notecount of ${ktchiChart.notedata.notecount}).`
);
}
const lamp = EAMUSEMENT_LAMP_RESOLVER.get(eamScore.lamp);
if (!lamp) {
logger.error(`Invalid lamp of ${eamScore.lamp} provided.`);
throw new InvalidScoreFailure(
`${HUMANISED_SONG_TITLE} - Invalid Lamp of ${eamScore.lamp}.`
);
}
const percent = (100 * eamScore.exscore) / ktchiChart.notedata.notecount;
const grade = GetGradeFromPercent<"iidx:SP" | "iidx:DP">("iidx", percent);
if (!grade) {
throw new InternalFailure(
`${HUMANISED_SONG_TITLE} - Could not resolve ${percent} into a grade?`
);
}
let esd = 200;
// esd cannot estimate things below this level of accuracy, so only actually calculate it here
if (percent > 0.1) {
esd = ESDCore.CalculateESD(config.judgementWindows.iidx[context.playtype], percent);
}
// Now we need to figure out the timestamp for this score.
// Under, well, normal circumstances, we could figure this out quite trivially
// But e-amusement provides us the timestamp for the *song*, not the score
//
// We're going to actually ignore this. Initial drafts of this assumed we could skip the
// "epochs" (when a score reset on eamusement happened), but it turns out those are
// generated when the user triggers a score migration, NOT (as initially thought) when
// KONAMI decides.
let timestamp = Date.parse(data.timestamp);
let dryScore: DryScore<"iidx:SP" | "iidx:DP"> = {
service: context.serviceOrigin,
comment: null,
game: "iidx",
importType: "csv:eamusement-iidx",
scoreData: {
score: eamScore.exscore,
lamp,
hitData: {
pgreat: eamScore.pgreat,
great: eamScore.great,
},
hitMeta: {},
percent,
grade,
esd,
},
scoreMeta: {},
timeAchieved: timestamp,
};
if (typeof eamScore.bp === "number") {
if (!Number.isInteger(eamScore.bp)) {
throw new InvalidScoreFailure(`${HUMANISED_SONG_TITLE} - Invalid BP of ${eamScore.bp}`);
}
dryScore.scoreData.hitMeta.bp = eamScore.bp;
} else if (eamScore.bp === "---") {
logger.verbose(
`Skipped assigning BP for score as it had expected null value of ${eamScore.bp}.`
);
} else {
logger.warn(`Skipped assigning BP for score. Had unexpected value of ${eamScore.bp}.`);
}
return { ktchiChart, dryScore };
}
/**
* Thin wrapper around EamScoreConverter to ensure it returns the expected values
* @param eamScore - The eamusement score data to convert.
* @param song - The song this score is for.
* @param context - Context the converter may need.
* @param data - The parent data the eamScore derives from.
* @returns ConverterFnReturn
*/
async function EamScoreConverterWrapper(
eamScore: EamusementScoreData,
song: SongDocument,
context: IIDXEamusementCSVContext,
data: IIDXEamusementCSVData,
logger: Logger
) {
try {
let results = await EamScoreConverter(eamScore, song!, context, data, logger);
if (!results) {
return null;
}
return {
song,
chart: results.ktchiChart,
dryScore: results.dryScore,
};
} catch (err) {
if (
err instanceof KTDataNotFoundFailure ||
err instanceof InternalFailure ||
err instanceof InvalidScoreFailure
) {
return err;
} else if (err instanceof ScoreImportFatalError) {
throw err; // throw it all the way up.
} else {
logger.error(`Unknown error: `, { err });
return new InternalFailure("An unknown internal failure has occured.");
}
}
}
const ConverterFn: ConverterFunction<IIDXEamusementCSVData, IIDXEamusementCSVContext> = async (
data,
context,
logger: Logger
): Promise<ConverterFnReturn[]> => {
let ktchiSong = await FindSongOnTitleVersion("iidx", data.title, context.importVersion);
if (!ktchiSong) {
logger.warn(`Could not find song for ${data.title}.`);
throw new KTDataNotFoundFailure(data.title, "csv:eamusement-iidx", data, context);
}
// ts thinks ktchiSong might be null. It's not, though!
let results = await Promise.all(
data.scores.map((e) => EamScoreConverterWrapper(e, ktchiSong!, context, data, logger))
);
return results;
};
export default ConverterFn;
@@ -1,274 +1,274 @@
import csvParse from "csv-parse/lib/sync";
import { Difficulties, integer } from "kamaitachi-common";
import { Logger } from "winston";
import { ParserFunctionReturnsSync } from "../../../types";
import ScoreImportFatalError from "../../framework/core/score-import-error";
import ConverterFn from "./converter";
import {
EamusementScoreData,
IIDXEamusementCSVContext,
IIDXEamusementCSVData,
RawIIDXEamusementCSVData,
} from "./types";
const PRE_HV_HEADER_COUNT = 27;
const HV_HEADER_COUNT = 41;
enum EAM_VERSION_NAMES {
"1st&substream" = 1,
"2nd style",
"3rd style",
"4th style",
"5th style",
"6th style",
"7th style",
"8th style",
"9th style",
"10th style",
"IIDX RED",
"HAPPY SKY",
"DistorteD",
"GOLD",
"DJ TROOPERS",
"EMPRESS",
"SIRIUS",
"Resort Anthem",
"Lincle",
"tricoro",
"SPADA",
"PENDUAL",
"copula",
"SINOBUZ",
"CANNON BALLERS",
"Rootage",
"HEROIC VERSE",
"BISTROVER",
}
/**
* Parses a buffer of EamusementCSV data.
* @param fileData - The buffer to parse.
* @param body - The request body that made this file import request. Used to infer playtype.
*/
function ParseEamusementCSV(
fileData: Express.Multer.File,
body: Record<string, unknown>,
logger: Logger
): ParserFunctionReturnsSync<IIDXEamusementCSVData, IIDXEamusementCSVContext> {
let playtype: "SP" | "DP";
if (body.playtype === "SP") {
playtype = "SP";
} else if (body.playtype === "DP") {
playtype = "DP";
} else {
logger.error(`Invalid playtype of ${body.playtype} passed to ParseEamusementCSV.`);
throw new ScoreImportFatalError(
400,
`Invalid playtype of ${body.playtype ?? "Nothing"} given.`
);
}
let lowercaseFilename = fileData.filename.toLowerCase();
// prettier pls
if (
!body.assertPlaytypeCorrect &&
((lowercaseFilename.includes("sp") && playtype === "DP") ||
(lowercaseFilename.toLowerCase().includes("dp") && playtype === "SP"))
) {
logger.info(
`File was uploaded with filename ${fileData.filename}, but this was set as a ${playtype} import. Sanity check refusing.`
);
throw new ScoreImportFatalError(
400,
`Safety: Filename contained '${
playtype === "SP" ? "DP" : "SP"
}', but was marked as a ${playtype} import. Are you *absolutely* sure this is right?`
);
}
let data: IIDXEamusementCSVData[] = [];
let csvData: RawIIDXEamusementCSVData[];
let hasBeginnerAndLegg: boolean | null = null;
try {
csvData = csvParse(fileData.buffer, {
bom: true,
// @ts-expect-error csvParse's types are wrong, see https://github.com/adaltas/node-csv-parse/pull/314
escape: null, // KONMAI do not escape their CSV, disable escaping entirely.
columns: (header) => {
// does not have leggendaria/beginner built in
if (header.length === PRE_HV_HEADER_COUNT) {
logger.verbose("PRE_HV csv recieved.");
hasBeginnerAndLegg = false;
return [
"version",
"title",
"genre",
"artist",
"playcount",
"normal-level",
"normal-exscore",
"normal-pgreat",
"normal-great",
"normal-bp",
"normal-lamp",
"normal-grade",
"hyper-level",
"hyper-exscore",
"hyper-pgreat",
"hyper-great",
"hyper-bp",
"hyper-lamp",
"hyper-grade",
"another-level",
"another-exscore",
"another-pgreat",
"another-great",
"another-bp",
"another-lamp",
"another-grade",
"timestamp",
];
}
// does
else if (header.length === HV_HEADER_COUNT) {
logger.verbose("HV+ csv recieved.");
hasBeginnerAndLegg = true;
return [
"version",
"title",
"genre",
"artist",
"playcount",
"beginner-level",
"beginner-exscore",
"beginner-pgreat",
"beginner-great",
"beginner-bp",
"beginner-lamp",
"beginner-grade",
"normal-level",
"normal-exscore",
"normal-pgreat",
"normal-great",
"normal-bp",
"normal-lamp",
"normal-grade",
"hyper-level",
"hyper-exscore",
"hyper-pgreat",
"hyper-great",
"hyper-bp",
"hyper-lamp",
"hyper-grade",
"another-level",
"another-exscore",
"another-pgreat",
"another-great",
"another-bp",
"another-lamp",
"another-grade",
"leggendaria-level",
"leggendaria-exscore",
"leggendaria-pgreat",
"leggendaria-great",
"leggendaria-bp",
"leggendaria-lamp",
"leggendaria-grade",
"timestamp",
];
} else {
logger.warn(`Invalid CSV header count of ${header.length} received.`);
throw new ScoreImportFatalError(
400,
"Invalid CSV provided. CSV does not have the correct amount of headers."
);
}
},
skipEmptyLines: true,
});
} catch (err) {
logger.error(`CSV Parser Error: ${err}`);
throw new ScoreImportFatalError(400, "CSV Could not be parsed.");
}
logger.verbose("Successfully parsed CSV.");
let firstEl = csvData[0];
if (!firstEl) {
throw new ScoreImportFatalError(400, "This CSV has no scores.");
}
let version = 0;
const diffs = hasBeginnerAndLegg
? ["beginner", "normal", "hyper", "another", "leggendaria"]
: ["normal", "hyper", "another"];
// @optimisable - Can hook into the CSV parser perhaps and read this value immediately?
for (const d of csvData) {
// wtf typescript?? what's the point of enums?
const versionNum = EAM_VERSION_NAMES[d.version as keyof typeof EAM_VERSION_NAMES];
if (!versionNum) {
logger.error(`Invalid/Unknown EAM_VERSION_NAME ${d.version}.`);
throw new ScoreImportFatalError(400, `Invalid/Unknown EAM_VERSION_NAME ${d.version}.`);
}
if (versionNum > version) {
logger.verbose(`Replaced ${version} with ${d.version} (${versionNum}).`);
version = versionNum;
}
let scores: EamusementScoreData[] = [];
for (const diff of diffs) {
scores.push({
difficulty: (diff.toUpperCase as unknown) as Difficulties["iidx:SP" | "iidx:DP"],
bp: d[`${diff}-bp`] as integer | "---",
exscore: d[`${diff}-exscore`] as integer,
pgreat: d[`${diff}-pgreat`] as integer,
great: d[`${diff}-great`] as integer,
lamp: d[`${diff}-lamp`] as string,
level: d[`${diff}-level`] as integer,
});
}
data.push({
scores,
artist: d.artist,
genre: d.genre,
playcount: d.playcount,
timestamp: d.timestamp,
title: d.title,
version: d.version,
});
}
if (hasBeginnerAndLegg === null) {
logger.error(`hasBeginnerAndLegg was not set, but the end of parsings was reached?`);
throw new ScoreImportFatalError(500, "An internal service error has occured.");
}
let context: IIDXEamusementCSVContext = {
playtype,
importVersion: version,
hasBeginnerAndLegg,
serviceOrigin: "e-amusement",
};
logger.verbose(`Successfully Parsed with ${data.length} results.`);
return {
iterable: data,
context,
ConverterFunction: ConverterFn,
idStrings: [`iidx:${context.playtype}` as "iidx:SP" | "iidx:DP"],
};
}
export default ParseEamusementCSV;
import csvParse from "csv-parse/lib/sync";
import { Difficulties, integer } from "kamaitachi-common";
import { Logger } from "winston";
import { ParserFunctionReturnsSync } from "../../../types";
import ScoreImportFatalError from "../../framework/core/score-import-error";
import ConverterFn from "./converter";
import {
EamusementScoreData,
IIDXEamusementCSVContext,
IIDXEamusementCSVData,
RawIIDXEamusementCSVData,
} from "./types";
const PRE_HV_HEADER_COUNT = 27;
const HV_HEADER_COUNT = 41;
enum EAM_VERSION_NAMES {
"1st&substream" = 1,
"2nd style",
"3rd style",
"4th style",
"5th style",
"6th style",
"7th style",
"8th style",
"9th style",
"10th style",
"IIDX RED",
"HAPPY SKY",
"DistorteD",
"GOLD",
"DJ TROOPERS",
"EMPRESS",
"SIRIUS",
"Resort Anthem",
"Lincle",
"tricoro",
"SPADA",
"PENDUAL",
"copula",
"SINOBUZ",
"CANNON BALLERS",
"Rootage",
"HEROIC VERSE",
"BISTROVER",
}
/**
* Parses a buffer of EamusementCSV data.
* @param fileData - The buffer to parse.
* @param body - The request body that made this file import request. Used to infer playtype.
*/
function ParseEamusementCSV(
fileData: Express.Multer.File,
body: Record<string, unknown>,
logger: Logger
): ParserFunctionReturnsSync<IIDXEamusementCSVData, IIDXEamusementCSVContext> {
let playtype: "SP" | "DP";
if (body.playtype === "SP") {
playtype = "SP";
} else if (body.playtype === "DP") {
playtype = "DP";
} else {
logger.error(`Invalid playtype of ${body.playtype} passed to ParseEamusementCSV.`);
throw new ScoreImportFatalError(
400,
`Invalid playtype of ${body.playtype ?? "Nothing"} given.`
);
}
let lowercaseFilename = fileData.filename.toLowerCase();
// prettier pls
if (
!body.assertPlaytypeCorrect &&
((lowercaseFilename.includes("sp") && playtype === "DP") ||
(lowercaseFilename.toLowerCase().includes("dp") && playtype === "SP"))
) {
logger.info(
`File was uploaded with filename ${fileData.filename}, but this was set as a ${playtype} import. Sanity check refusing.`
);
throw new ScoreImportFatalError(
400,
`Safety: Filename contained '${
playtype === "SP" ? "DP" : "SP"
}', but was marked as a ${playtype} import. Are you *absolutely* sure this is right?`
);
}
let data: IIDXEamusementCSVData[] = [];
let csvData: RawIIDXEamusementCSVData[];
let hasBeginnerAndLegg: boolean | null = null;
try {
csvData = csvParse(fileData.buffer, {
bom: true,
// @ts-expect-error csvParse's types are wrong, see https://github.com/adaltas/node-csv-parse/pull/314
escape: null, // KONMAI do not escape their CSV, disable escaping entirely.
columns: (header) => {
// does not have leggendaria/beginner built in
if (header.length === PRE_HV_HEADER_COUNT) {
logger.verbose("PRE_HV csv recieved.");
hasBeginnerAndLegg = false;
return [
"version",
"title",
"genre",
"artist",
"playcount",
"normal-level",
"normal-exscore",
"normal-pgreat",
"normal-great",
"normal-bp",
"normal-lamp",
"normal-grade",
"hyper-level",
"hyper-exscore",
"hyper-pgreat",
"hyper-great",
"hyper-bp",
"hyper-lamp",
"hyper-grade",
"another-level",
"another-exscore",
"another-pgreat",
"another-great",
"another-bp",
"another-lamp",
"another-grade",
"timestamp",
];
}
// does
else if (header.length === HV_HEADER_COUNT) {
logger.verbose("HV+ csv recieved.");
hasBeginnerAndLegg = true;
return [
"version",
"title",
"genre",
"artist",
"playcount",
"beginner-level",
"beginner-exscore",
"beginner-pgreat",
"beginner-great",
"beginner-bp",
"beginner-lamp",
"beginner-grade",
"normal-level",
"normal-exscore",
"normal-pgreat",
"normal-great",
"normal-bp",
"normal-lamp",
"normal-grade",
"hyper-level",
"hyper-exscore",
"hyper-pgreat",
"hyper-great",
"hyper-bp",
"hyper-lamp",
"hyper-grade",
"another-level",
"another-exscore",
"another-pgreat",
"another-great",
"another-bp",
"another-lamp",
"another-grade",
"leggendaria-level",
"leggendaria-exscore",
"leggendaria-pgreat",
"leggendaria-great",
"leggendaria-bp",
"leggendaria-lamp",
"leggendaria-grade",
"timestamp",
];
} else {
logger.warn(`Invalid CSV header count of ${header.length} received.`);
throw new ScoreImportFatalError(
400,
"Invalid CSV provided. CSV does not have the correct amount of headers."
);
}
},
skipEmptyLines: true,
});
} catch (err) {
logger.error(`CSV Parser Error: ${err}`);
throw new ScoreImportFatalError(400, "CSV Could not be parsed.");
}
logger.verbose("Successfully parsed CSV.");
let firstEl = csvData[0];
if (!firstEl) {
throw new ScoreImportFatalError(400, "This CSV has no scores.");
}
let version = 0;
const diffs = hasBeginnerAndLegg
? ["beginner", "normal", "hyper", "another", "leggendaria"]
: ["normal", "hyper", "another"];
// @optimisable - Can hook into the CSV parser perhaps and read this value immediately?
for (const d of csvData) {
// wtf typescript?? what's the point of enums?
const versionNum = EAM_VERSION_NAMES[d.version as keyof typeof EAM_VERSION_NAMES];
if (!versionNum) {
logger.error(`Invalid/Unknown EAM_VERSION_NAME ${d.version}.`);
throw new ScoreImportFatalError(400, `Invalid/Unknown EAM_VERSION_NAME ${d.version}.`);
}
if (versionNum > version) {
logger.verbose(`Replaced ${version} with ${d.version} (${versionNum}).`);
version = versionNum;
}
let scores: EamusementScoreData[] = [];
for (const diff of diffs) {
scores.push({
difficulty: (diff.toUpperCase as unknown) as Difficulties["iidx:SP" | "iidx:DP"],
bp: d[`${diff}-bp`] as integer | "---",
exscore: d[`${diff}-exscore`] as integer,
pgreat: d[`${diff}-pgreat`] as integer,
great: d[`${diff}-great`] as integer,
lamp: d[`${diff}-lamp`] as string,
level: d[`${diff}-level`] as integer,
});
}
data.push({
scores,
artist: d.artist,
genre: d.genre,
playcount: d.playcount,
timestamp: d.timestamp,
title: d.title,
version: d.version,
});
}
if (hasBeginnerAndLegg === null) {
logger.error(`hasBeginnerAndLegg was not set, but the end of parsings was reached?`);
throw new ScoreImportFatalError(500, "An internal service error has occured.");
}
let context: IIDXEamusementCSVContext = {
playtype,
importVersion: version,
hasBeginnerAndLegg,
serviceOrigin: "e-amusement",
};
logger.verbose(`Successfully Parsed with ${data.length} results.`);
return {
iterable: data,
context,
ConverterFunction: ConverterFn,
idStrings: [`iidx:${context.playtype}` as "iidx:SP" | "iidx:DP"],
};
}
export default ParseEamusementCSV;
@@ -1,40 +1,40 @@
import { integer } from "kamaitachi-common";
export interface EamusementScoreData {
difficulty: "BEGINNER" | "NORMAL" | "HYPER" | "ANOTHER" | "LEGGENDARIA";
lamp: string;
exscore: integer;
pgreat: integer;
great: integer;
bp: integer | "---"; // lol
level: integer;
}
type BaseProps = {
version: string;
title: string;
genre: string;
artist: string;
playcount: number;
timestamp: string;
};
export type IIDXEamusementCSVData = {
scores: EamusementScoreData[];
} & BaseProps;
type Props = "exscore" | "lamp" | "pgreat" | "great" | "bp" | "level";
type RawPropKeys = `${"beginner" | "normal" | "hyper" | "another" | "leggendaria"}-${Props}`;
export type RawIIDXEamusementCSVData = {
[K in RawPropKeys]: unknown;
} &
BaseProps & { [index: string]: unknown };
export interface IIDXEamusementCSVContext {
playtype: "SP" | "DP";
importVersion: integer;
hasBeginnerAndLegg: boolean;
serviceOrigin: string;
}
import { integer } from "kamaitachi-common";
export interface EamusementScoreData {
difficulty: "BEGINNER" | "NORMAL" | "HYPER" | "ANOTHER" | "LEGGENDARIA";
lamp: string;
exscore: integer;
pgreat: integer;
great: integer;
bp: integer | "---"; // lol
level: integer;
}
type BaseProps = {
version: string;
title: string;
genre: string;
artist: string;
playcount: number;
timestamp: string;
};
export type IIDXEamusementCSVData = {
scores: EamusementScoreData[];
} & BaseProps;
type Props = "exscore" | "lamp" | "pgreat" | "great" | "bp" | "level";
type RawPropKeys = `${"beginner" | "normal" | "hyper" | "another" | "leggendaria"}-${Props}`;
export type RawIIDXEamusementCSVData = {
[K in RawPropKeys]: unknown;
} &
BaseProps & { [index: string]: unknown };
export interface IIDXEamusementCSVContext {
playtype: "SP" | "DP";
importVersion: integer;
hasBeginnerAndLegg: boolean;
serviceOrigin: string;
}
@@ -1,29 +1,29 @@
import { ImportTypes } from "kamaitachi-common";
import iidxEamusementCsv from "./csv-eamusement-iidx/converter";
/**
* Converters are a set of functions designed to convert an element
* from the iterable into the Kamaitachi Score Format.
*
* Due to some oddities with certain formats, one element may contain multiple possible
* score records, so these functions MAY return an array of their traditional values.
*/
export const Converters = {
"csv:eamusement-iidx": iidxEamusementCsv,
};
/**
* Parsers are a set of functions designed to convert unparsed data
* (files, request bodies, api responses) into an iterable.
*
* These functions should also perform validation on the "data" recieved,
* such as whether they are valid JSON/csv files.
*
* However, they need not validate the actual data recieved - only that it is
* parsable and sensible, but not whether the contents are semantically correct
* (i.e. just parse the JSON into an array, and check if it can be done)
* (don't check the content!)
*
* This is so we don't end up with dual-iteration over the set of data.
*/
export const Parsers = {};
import { ImportTypes } from "kamaitachi-common";
import iidxEamusementCsv from "./csv-eamusement-iidx/converter";
/**
* Converters are a set of functions designed to convert an element
* from the iterable into the Kamaitachi Score Format.
*
* Due to some oddities with certain formats, one element may contain multiple possible
* score records, so these functions MAY return an array of their traditional values.
*/
export const Converters = {
"csv:eamusement-iidx": iidxEamusementCsv,
};
/**
* Parsers are a set of functions designed to convert unparsed data
* (files, request bodies, api responses) into an iterable.
*
* These functions should also perform validation on the "data" recieved,
* such as whether they are valid JSON/csv files.
*
* However, they need not validate the actual data recieved - only that it is
* parsable and sensible, but not whether the contents are semantically correct
* (i.e. just parse the JSON into an array, and check if it can be done)
* (don't check the content!)
*
* This is so we don't end up with dual-iteration over the set of data.
*/
export const Parsers = {};
File diff suppressed because it is too large Load Diff
+101 -101
View File
@@ -1,101 +1,101 @@
/* eslint-disable no-await-in-loop */
import fetch from "node-fetch";
import { Command } from "commander";
import fs from "fs";
import path from "path";
import { config, Difficulties, ESDCore, IIDXBPIData } from "kamaitachi-common";
import { FindSongOnTitleVersion } from "../score-import/database-lookup/song-title";
import { FindChartWithPTDF } from "../score-import/database-lookup/chart-ptdf";
import db from "../db/db";
import CreateLogCtx from "../logger";
const program = new Command();
const logger = CreateLogCtx("update-bpi-poyashi.ts");
program.option("-f, --fetch", "Fetch the latest data from the poyashi repo.");
program.parse(process.argv);
const options = program.opts();
const dataLoc = path.join(__dirname, "../bpi-poyashi-data/output.json");
const difficultyResolve: Record<string, [string, string]> = {
3: ["SP", "HYPER"],
4: ["SP", "ANOTHER"],
8: ["DP", "HYPER"],
9: ["DP", "ANOTHER"],
10: ["SP", "LEGGENDARIA"],
11: ["DP", "LEGGENDARIA"],
};
async function UpdatePoyashiData() {
let data;
if (options.fetch) {
// lol!
logger.info("Fetching data from github...");
let rj = await fetch(
"https://raw.githubusercontent.com/potakusan/bpim-score-repo/master/output/release.json"
).then((r) => r.json());
logger.info("Fetched data.");
fs.writeFileSync(dataLoc, JSON.stringify(rj));
logger.info("Saved data to output.json.");
data = rj;
} else {
logger.info("Reading data from output.json...");
data = JSON.parse(fs.readFileSync(dataLoc, "utf-8"));
}
let realData: IIDXBPIData[] = [];
for (const d of data) {
const res = difficultyResolve[d.difficulty];
if (!res) {
throw new Error(`Unknown difficulty ${d.difficulty}`);
}
const [playtype, diff] = res;
let ktchiSong = await FindSongOnTitleVersion("iidx", d.title, 28);
if (!ktchiSong) {
throw new Error(`Cannot find song ${d.title}?`);
}
let ktchiChart = await FindChartWithPTDF(
"iidx",
ktchiSong.id,
playtype as "SP" | "DP",
diff as Difficulties["iidx:DP" | "iidx:SP"]
);
if (!ktchiChart) {
throw new Error(
`Cannot find chart ${ktchiSong.title} (${ktchiSong.id}) ${playtype}, ${diff}?`
);
}
let kavg = parseInt(d.avg);
let kesd = ESDCore.CalculateESD(
config.judgementWindows.iidx.SP,
kavg / (ktchiChart.notedata.notecount * 2)
);
realData.push({
coef: d.coef === -1 ? null : d.coef,
kavg: parseInt(d.avg),
wr: parseInt(d.wr),
chartID: ktchiChart.chartID,
kesd,
});
}
await db["iidx-bpi-data"].remove({});
await db["iidx-bpi-data"].insert(realData);
logger.info(`Inserted ${realData.length} documents.`);
}
UpdatePoyashiData();
/* eslint-disable no-await-in-loop */
import fetch from "node-fetch";
import { Command } from "commander";
import fs from "fs";
import path from "path";
import { config, Difficulties, ESDCore, IIDXBPIData } from "kamaitachi-common";
import { FindSongOnTitleVersion } from "../score-import/database-lookup/song-title";
import { FindChartWithPTDF } from "../score-import/database-lookup/chart-ptdf";
import db from "../db/db";
import CreateLogCtx from "../logger";
const program = new Command();
const logger = CreateLogCtx("update-bpi-poyashi.ts");
program.option("-f, --fetch", "Fetch the latest data from the poyashi repo.");
program.parse(process.argv);
const options = program.opts();
const dataLoc = path.join(__dirname, "../bpi-poyashi-data/output.json");
const difficultyResolve: Record<string, [string, string]> = {
3: ["SP", "HYPER"],
4: ["SP", "ANOTHER"],
8: ["DP", "HYPER"],
9: ["DP", "ANOTHER"],
10: ["SP", "LEGGENDARIA"],
11: ["DP", "LEGGENDARIA"],
};
async function UpdatePoyashiData() {
let data;
if (options.fetch) {
// lol!
logger.info("Fetching data from github...");
let rj = await fetch(
"https://raw.githubusercontent.com/potakusan/bpim-score-repo/master/output/release.json"
).then((r) => r.json());
logger.info("Fetched data.");
fs.writeFileSync(dataLoc, JSON.stringify(rj));
logger.info("Saved data to output.json.");
data = rj;
} else {
logger.info("Reading data from output.json...");
data = JSON.parse(fs.readFileSync(dataLoc, "utf-8"));
}
let realData: IIDXBPIData[] = [];
for (const d of data) {
const res = difficultyResolve[d.difficulty];
if (!res) {
throw new Error(`Unknown difficulty ${d.difficulty}`);
}
const [playtype, diff] = res;
let ktchiSong = await FindSongOnTitleVersion("iidx", d.title, 28);
if (!ktchiSong) {
throw new Error(`Cannot find song ${d.title}?`);
}
let ktchiChart = await FindChartWithPTDF(
"iidx",
ktchiSong.id,
playtype as "SP" | "DP",
diff as Difficulties["iidx:DP" | "iidx:SP"]
);
if (!ktchiChart) {
throw new Error(
`Cannot find chart ${ktchiSong.title} (${ktchiSong.id}) ${playtype}, ${diff}?`
);
}
let kavg = parseInt(d.avg);
let kesd = ESDCore.CalculateESD(
config.judgementWindows.iidx.SP,
kavg / (ktchiChart.notedata.notecount * 2)
);
realData.push({
coef: d.coef === -1 ? null : d.coef,
kavg: parseInt(d.avg),
wr: parseInt(d.wr),
chartID: ktchiChart.chartID,
kesd,
});
}
await db["iidx-bpi-data"].remove({});
await db["iidx-bpi-data"].insert(realData);
logger.info(`Inserted ${realData.length} documents.`);
}
UpdatePoyashiData();
+7
View File
@@ -0,0 +1,7 @@
import supertest from "supertest";
import api from "../internal-api/internal-api";
const mockApi = supertest(api);
export default mockApi;
+22 -22
View File
@@ -1,23 +1,23 @@
[
{
"chartID": "c2311194e3897ddb5745b1760d2c0141f933e683",
"rgcID": null,
"levelNum": 10,
"level": "10",
"difficulty": "ANOTHER",
"playtype": "SP",
"length": "1:26.095",
"bpmString": "101",
"id": 1,
"flags": {
"IN BASE GAME": true,
"OMNIMIX": true,
"N-1": true
},
"data": {
"notecount": 786,
"inGameID": "1000",
"inGameINTID": 1000
}
}
[
{
"chartID": "c2311194e3897ddb5745b1760d2c0141f933e683",
"rgcID": null,
"levelNum": 10,
"level": "10",
"difficulty": "ANOTHER",
"playtype": "SP",
"length": "1:26.095",
"bpmString": "101",
"id": 1,
"flags": {
"IN BASE GAME": true,
"OMNIMIX": true,
"N-1": true
},
"data": {
"notecount": 786,
"inGameID": "1000",
"inGameINTID": 1000
}
}
]
+5 -5
View File
@@ -1,6 +1,6 @@
[
{
"counterName": "real-counter",
"value": 2
}
[
{
"counterName": "real-counter",
"value": 2
}
]
@@ -1,7 +1,7 @@
[{
"coef": null,
"kavg": 1300,
"wr": 1400,
"chartID": "c2311194e3897ddb5745b1760d2c0141f933e683",
"kesd": 13
[{
"coef": null,
"kavg": 1300,
"wr": 1400,
"chartID": "c2311194e3897ddb5745b1760d2c0141f933e683",
"kesd": 13
}]
+40 -40
View File
@@ -1,41 +1,41 @@
[
{
"service": "e-amusement",
"game": "iidx",
"playtype": "SP",
"difficulty": "ANOTHER",
"userID": 1,
"scoreData": {
"score": 786,
"lamp": "HARD CLEAR",
"percent": 50,
"grade": "C",
"esd": 30,
"gradeIndex": 3,
"lampIndex": 4,
"hitData": {
"pgreat": 50,
"great": 10
},
"hitMeta": {}
},
"scoreMeta": {},
"calculatedData": {
"rating": 0.1,
"lampRating": 5,
"gameSpecific": {},
"ranking": null,
"outOf": null
},
"timeAchieved": null,
"songID": 1,
"chartID": "c2311194e3897ddb5745b1760d2c0141f933e683",
"highlight": false,
"comment": null,
"timeAdded": 1,
"isScorePB": true,
"isLampPB": true,
"scoreID": "TESTING_SCORE_ID",
"importType": "iidx:eamusement-csv"
}
[
{
"service": "e-amusement",
"game": "iidx",
"playtype": "SP",
"difficulty": "ANOTHER",
"userID": 1,
"scoreData": {
"score": 786,
"lamp": "HARD CLEAR",
"percent": 50,
"grade": "C",
"esd": 30,
"gradeIndex": 3,
"lampIndex": 4,
"hitData": {
"pgreat": 50,
"great": 10
},
"hitMeta": {}
},
"scoreMeta": {},
"calculatedData": {
"rating": 0.1,
"lampRating": 5,
"gameSpecific": {},
"ranking": null,
"outOf": null
},
"timeAchieved": null,
"songID": 1,
"chartID": "c2311194e3897ddb5745b1760d2c0141f933e683",
"highlight": false,
"comment": null,
"timeAdded": 1,
"isScorePB": true,
"isLampPB": true,
"scoreID": "TESTING_SCORE_ID",
"importType": "iidx:eamusement-csv"
}
]
+22 -22
View File
@@ -1,23 +1,23 @@
[
{
"username": "zkldi",
"usernameLowercase": "zkldi",
"password": "$2y$12$u2FK6QJAv/WDF/ExGUWI9OoIks8Q0ExT1qjPMBu6zCZ5z72swQ01K",
"email": "thepasswordis@password.com",
"id": 1,
"settings": {
"nsfwSplashes": true,
"invisible": false
},
"friends": [],
"socialMedia": {},
"lastSeen": null,
"about": "test_user_not_real",
"customPfp": true,
"customBanner": true,
"permissions": {
"admin": true
},
"clan": null
}
[
{
"username": "zkldi",
"usernameLowercase": "zkldi",
"password": "$2y$12$u2FK6QJAv/WDF/ExGUWI9OoIks8Q0ExT1qjPMBu6zCZ5z72swQ01K",
"email": "thepasswordis@password.com",
"id": 1,
"settings": {
"nsfwSplashes": true,
"invisible": false
},
"friends": [],
"socialMedia": {},
"lastSeen": null,
"about": "test_user_not_real",
"customPfp": true,
"customBanner": true,
"permissions": {
"admin": true
},
"clan": null
}
]
+7 -7
View File
@@ -1,7 +1,7 @@
import nodeFetch, { Response } from "node-fetch";
export function MockFetch(data: Partial<Response>) {
// We know! this is a mock function.
// eslint-disable-next-line require-await
return ((async () => data) as unknown) as typeof nodeFetch;
}
import nodeFetch, { Response } from "node-fetch";
export function MockFetch(data: Partial<Response>) {
// We know! this is a mock function.
// eslint-disable-next-line require-await
return ((async () => data) as unknown) as typeof nodeFetch;
}
+56 -56
View File
@@ -1,56 +1,56 @@
/**
* Resets the state of the database.
*/
import db from "../db/db";
import fs from "fs";
import path from "path";
import CreateLogCtx from "../logger";
const logger = CreateLogCtx("reset-db-state.ts");
const DATA_DIR = path.join(__dirname, "./mock-db");
async function ResetState(file: string) {
let filename = path.basename(file, ".json");
let collection;
try {
if (filename.startsWith("songs-")) {
// @ts-expect-error it's right, but we know what we're doing!
collection = db.songs[filename.split("-")[1]];
} else if (filename.startsWith("charts-")) {
// @ts-expect-error see above
collection = db.charts[filename.split("-")[1]];
} else {
// @ts-expect-error see above
collection = db[filename];
}
await collection.remove({});
let fileLoc = path.join(DATA_DIR, file);
let data = JSON.parse(fs.readFileSync(fileLoc, "utf-8"));
if (!Array.isArray(data)) {
throw new Error(`Panic, ${filename} not JSONArray?`);
}
await collection.insert(data);
} catch (err) {
logger.crit(`Fatal in ResetState: ${filename} ${err}`);
throw err;
}
}
export default async function ResetDBState() {
const files = fs.readdirSync(DATA_DIR);
const promises = [];
for (const file of files) {
promises.push(ResetState(file));
}
await Promise.all(promises);
}
/**
* Resets the state of the database.
*/
import db from "../db/db";
import fs from "fs";
import path from "path";
import CreateLogCtx from "../logger";
const logger = CreateLogCtx("reset-db-state.ts");
const DATA_DIR = path.join(__dirname, "./mock-db");
async function ResetState(file: string) {
let filename = path.basename(file, ".json");
let collection;
try {
if (filename.startsWith("songs-")) {
// @ts-expect-error it's right, but we know what we're doing!
collection = db.songs[filename.split("-")[1]];
} else if (filename.startsWith("charts-")) {
// @ts-expect-error see above
collection = db.charts[filename.split("-")[1]];
} else {
// @ts-expect-error see above
collection = db[filename];
}
await collection.remove({});
let fileLoc = path.join(DATA_DIR, file);
let data = JSON.parse(fs.readFileSync(fileLoc, "utf-8"));
if (!Array.isArray(data)) {
throw new Error(`Panic, ${filename} not JSONArray?`);
}
await collection.insert(data);
} catch (err) {
logger.crit(`Fatal in ResetState: ${filename} ${err}`);
throw err;
}
}
export default async function ResetDBState() {
const files = fs.readdirSync(DATA_DIR);
const promises = [];
for (const file of files) {
promises.push(ResetState(file));
}
await Promise.all(promises);
}
+34 -28
View File
@@ -1,28 +1,34 @@
import fs from "fs";
import { ChartDocument } from "kamaitachi-common";
import path from "path";
import { DryScore } from "../types";
const file = (name: string) => path.join(__dirname, name);
const JSONFile = (name: string) => JSON.parse(fs.readFileSync(file(name), "utf-8"));
export const TestingIIDXSPDryScore = JSONFile(
"./test-data/kamaitachi/iidx-dryscore.json"
) as DryScore<"iidx:SP">;
export const Testing511SPA = JSONFile(
"./test-data/kamaitachi/iidx-511spa.json"
) as ChartDocument<"iidx:SP">;
export const TestingDoraChart = JSONFile(
"./test-data/kamaitachi/gitadora-ithinkabtyou.json"
) as ChartDocument<"gitadora:Dora">;
export const TestingSDVXSingleDryScore = JSONFile(
"./test-data/kamaitachi/sdvx-dryscore.json"
) as DryScore<"sdvx:Single">;
export const TestingGITADORADoraDryScore = JSONFile(
"./test-data/kamaitachi/gitadora-dryscore.json"
) as DryScore<"gitadora:Dora">;
import fs from "fs";
import { ChartDocument } from "kamaitachi-common";
import path from "path";
import { DryScore } from "../types";
const file = (name: string) => path.join(__dirname, "/test-data", name);
const JSONFile = (name: string) => JSON.parse(fs.readFileSync(file(name), "utf-8"));
const BufferFromFile = (name: string) => fs.readFileSync(file(name));
export const TestingIIDXSPDryScore = JSONFile(
"./kamaitachi/iidx-dryscore.json"
) as DryScore<"iidx:SP">;
export const Testing511SPA = JSONFile("./kamaitachi/iidx-511spa.json") as ChartDocument<"iidx:SP">;
export const TestingDoraChart = JSONFile(
"./kamaitachi/gitadora-ithinkabtyou.json"
) as ChartDocument<"gitadora:Dora">;
export const TestingSDVXSingleDryScore = JSONFile(
"./kamaitachi/sdvx-dryscore.json"
) as DryScore<"sdvx:Single">;
export const TestingGITADORADoraDryScore = JSONFile(
"./kamaitachi/gitadora-dryscore.json"
) as DryScore<"gitadora:Dora">;
export const TestingIIDXEamusementCSV26 = BufferFromFile(
"./csv-eamusement-iidx/pre-leggendaria.csv"
);
export const TestingIIDXEamusementCSV27 = BufferFromFile(
"./csv-eamusement-iidx/post-leggendaria.csv"
);
@@ -1,17 +1,17 @@
{
"service": "fake-service",
"game": "gitadora",
"scoreData": {
"score": 70.76,
"lamp": "CLEAR",
"percent": 70.76,
"grade": "B",
"esd": 30,
"hitData": {},
"hitMeta": {}
},
"scoreMeta": {},
"timeAchieved": null,
"comment": null,
"importType": "any:batch-manual"
{
"service": "fake-service",
"game": "gitadora",
"scoreData": {
"score": 70.76,
"lamp": "CLEAR",
"percent": 70.76,
"grade": "B",
"esd": 30,
"hitData": {},
"hitMeta": {}
},
"scoreMeta": {},
"timeAchieved": null,
"comment": null,
"importType": "any:batch-manual"
}
@@ -1,34 +1,34 @@
{
"id": 0,
"chartID": "29f0bfab357ba54e3fd0176fb3cbc578c9ec8df5",
"difficulty": "BASIC",
"playtype": "Dora",
"levelNum": 1.6,
"level": "1.60",
"notedata": {
"notecount": 169,
"objects": {
"LEFT CYMBAL": 54,
"HI HAT": 78,
"LEFT PEDAL": 24,
"SNARE": 1,
"HI TOM": 1,
"RIGHT PEDAL": 2,
"LOW TOM": 4,
"FLOOR TOM": 5,
"RIGHT CYMBAL": 0
}
},
"length": null,
"bpmMin": 117,
"bpmMax": 117,
"monoBPM": true,
"indicators": {},
"flags": {
"IN BASE GAME": true
},
"internals": {
"inGameID": "0",
"inGameINTID": 0
}
{
"id": 0,
"chartID": "29f0bfab357ba54e3fd0176fb3cbc578c9ec8df5",
"difficulty": "BASIC",
"playtype": "Dora",
"levelNum": 1.6,
"level": "1.60",
"notedata": {
"notecount": 169,
"objects": {
"LEFT CYMBAL": 54,
"HI HAT": 78,
"LEFT PEDAL": 24,
"SNARE": 1,
"HI TOM": 1,
"RIGHT PEDAL": 2,
"LOW TOM": 4,
"FLOOR TOM": 5,
"RIGHT CYMBAL": 0
}
},
"length": null,
"bpmMin": 117,
"bpmMax": 117,
"monoBPM": true,
"indicators": {},
"flags": {
"IN BASE GAME": true
},
"internals": {
"inGameID": "0",
"inGameINTID": 0
}
}
@@ -1,27 +1,27 @@
{
"chartID": "c2311194e3897ddb5745b1760d2c0141f933e683",
"levelNum": 10,
"level": "10",
"difficulty": "ANOTHER",
"playtype": "SP",
"length": 86095.46804246228,
"bpmString": "101",
"notedata": {
"objects": {
"BPM": 1,
"NOTE": 625,
"SCRATCH": 161
},
"notecount": 786
},
"id": 1,
"flags": {
"IN BASE GAME": true,
"OMNIMIX": true,
"N-1": true
},
"data": {
"inGameID": "1000",
"inGameINTID": 1000
}
{
"chartID": "c2311194e3897ddb5745b1760d2c0141f933e683",
"levelNum": 10,
"level": "10",
"difficulty": "ANOTHER",
"playtype": "SP",
"length": 86095.46804246228,
"bpmString": "101",
"notedata": {
"objects": {
"BPM": 1,
"NOTE": 625,
"SCRATCH": 161
},
"notecount": 786
},
"id": 1,
"flags": {
"IN BASE GAME": true,
"OMNIMIX": true,
"N-1": true
},
"data": {
"inGameID": "1000",
"inGameINTID": 1000
}
}
@@ -1,20 +1,20 @@
{
"service": "e-amusement",
"game": "iidx",
"scoreData": {
"score": 786,
"lamp": "HARD CLEAR",
"percent": 50,
"grade": "C",
"esd": 30,
"hitData": {
"pgreat": 50,
"great": 10
},
"hitMeta": {}
},
"scoreMeta": {},
"timeAchieved": null,
"comment": null,
"importType": "iidx:eamusement-csv"
{
"service": "e-amusement",
"game": "iidx",
"scoreData": {
"score": 786,
"lamp": "HARD CLEAR",
"percent": 50,
"grade": "C",
"esd": 30,
"hitData": {
"pgreat": 50,
"great": 10
},
"hitMeta": {}
},
"scoreMeta": {},
"timeAchieved": null,
"comment": null,
"importType": "iidx:eamusement-csv"
}
@@ -1,17 +1,17 @@
{
"service": "e-amusement",
"game": "sdvx",
"scoreData": {
"score": 95000000,
"lamp": "EXCESSIVE CLEAR",
"percent": 95,
"grade": "AA+",
"esd": null,
"hitData": {},
"hitMeta": {}
},
"scoreMeta": {},
"timeAchieved": null,
"comment": null,
"importType": "sdvx:eamusement-csv"
{
"service": "e-amusement",
"game": "sdvx",
"scoreData": {
"score": 95000000,
"lamp": "EXCESSIVE CLEAR",
"percent": 95,
"grade": "AA+",
"esd": null,
"hitData": {},
"hitMeta": {}
},
"scoreMeta": {},
"timeAchieved": null,
"comment": null,
"importType": "sdvx:eamusement-csv"
}
+7 -7
View File
@@ -1,8 +1,8 @@
{
"extends": "./tsconfig.json",
"exclude": [
"node_modules",
"src/test-utils",
"src/**/*.test.ts"
]
{
"extends": "./tsconfig.json",
"exclude": [
"node_modules",
"src/test-utils",
"src/**/*.test.ts"
]
}