mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-06 13:58:05 +03:00
??
This commit is contained in:
Vendored
+20
-20
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
+252
-252
@@ -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 = {};
|
||||
|
||||
+18950
-18950
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import supertest from "supertest";
|
||||
|
||||
import api from "../internal-api/internal-api";
|
||||
|
||||
const mockApi = supertest(api);
|
||||
|
||||
export default mockApi;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
+840
-840
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user