Database Update

This commit is contained in:
zkldi
2021-04-09 19:44:15 +01:00
parent 9ba1797272
commit 03aeea384c
23 changed files with 19605 additions and 90 deletions
+1 -2
View File
@@ -1,3 +1,2 @@
node_modules
js
scripts
js
+16 -2
View File
@@ -9,6 +9,7 @@
"license": "ISC",
"dependencies": {
"bcrypt": "^5.0.1",
"commander": "^7.2.0",
"connect-redis": "^5.1.0",
"csv-parse": "^4.15.3",
"escape-string-regexp": "^1.0.5",
@@ -1438,6 +1439,14 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"engines": {
"node": ">= 10"
}
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -3951,7 +3960,7 @@
},
"node_modules/kamaitachi-common": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/zkldi/Kamaitachi-common.git#c2147174d7be0e4cf9f36494021118d9518be4f7",
"resolved": "git+ssh://git@github.com/zkldi/Kamaitachi-common.git#51aeec20dfe8ffa5867cd6ef8b8d18e529e500f4",
"license": "ISC",
"dependencies": {
"monk": "^7.3.3"
@@ -9966,6 +9975,11 @@
"delayed-stream": "~1.0.0"
}
},
"commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="
},
"commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -12013,7 +12027,7 @@
}
},
"kamaitachi-common": {
"version": "git+ssh://git@github.com/zkldi/Kamaitachi-common.git#c2147174d7be0e4cf9f36494021118d9518be4f7",
"version": "git+ssh://git@github.com/zkldi/Kamaitachi-common.git#51aeec20dfe8ffa5867cd6ef8b8d18e529e500f4",
"from": "kamaitachi-common@git+https://github.com/zkldi/Kamaitachi-common.git",
"requires": {
"monk": "^7.3.3"
+1
View File
@@ -41,6 +41,7 @@
},
"dependencies": {
"bcrypt": "^5.0.1",
"commander": "^7.2.0",
"connect-redis": "^5.1.0",
"csv-parse": "^4.15.3",
"escape-string-regexp": "^1.0.5",
+22 -26
View File
@@ -1,13 +1,13 @@
import {
AddNewUserAPIKey,
CreateAPIKey,
CreateBetaKey,
AddNewBetakey,
ReinstateBetakey,
CreateInviteCode,
AddNewInvite,
ReinstateInvite,
} from "./auth-core";
import t from "tap";
import db, { CloseConnection } from "../db";
import { BetaKeyDocument, PrivateUserDocument } from "kamaitachi-common";
import { PrivateUserDocument } from "kamaitachi-common";
import prAssert from "../test-utils/prassert";
import Prudence from "prudence";
import ResetDBState from "../test-utils/reset-db-state";
@@ -45,7 +45,7 @@ t.test("#AddNewUserAPIKey", (t) => {
"Data should match a public API key object"
);
let inDatabase = await db.get("public-api-keys").findOne({
let inDatabase = await db["public-api-keys"].findOne({
_id: data._id,
});
@@ -57,28 +57,28 @@ t.test("#AddNewUserAPIKey", (t) => {
t.end();
});
t.test("#ReinstateBetaKey", (t) => {
t.test("#ReinstateInvite", (t) => {
t.beforeEach(ResetDBState);
t.test("Should change the 'consumed' property of a betakey to true.", async (t) => {
t.test("Should change the 'consumed' property of an invite to true.", async (t) => {
// mock insert
let betakey = await db.get<BetaKeyDocument>("betakeys").insert({
betakey: "foobar",
let inviteDoc = await db.invites.insert({
code: "foobar",
consumed: true,
createdBy: 1,
createdOn: 1,
});
let response = await ReinstateBetakey(betakey);
let response = await ReinstateInvite(inviteDoc);
// @ts-expect-error Monks' types are WRONG. this is nModified, not modifiedCount
t.is(response.nModified, 1, "Should modify one document");
let betakey2 = await db.get<BetaKeyDocument>("betakeys").findOne({
betakey: betakey.betakey, // lol
let invite2 = await db.invites.findOne({
code: inviteDoc.code, // lol
});
t.is(betakey2!.consumed, false, "Should no longer be consumed");
t.is(invite2!.consumed, false, "Should no longer be consumed");
t.end();
});
@@ -86,34 +86,30 @@ t.test("#ReinstateBetaKey", (t) => {
t.end();
});
t.test("#CreateBetaKey", (t) => {
t.match(CreateBetaKey(), /^[0-9a-f]{40}$/, "Betakey should be a 40 character hex string.");
t.test("#CreateInviteCode", (t) => {
t.match(CreateInviteCode(), /^[0-9a-f]{40}$/, "Invite should be a 40 character hex string.");
t.end();
});
t.test("#AddNewBetakey", (t) => {
t.test("#AddNewInvite", (t) => {
t.beforeEach(ResetDBState);
t.test("Should create a new beta key for a given user", async (t) => {
let userDoc = await db.get("users").findOne({ id: 1 });
let userDoc = await db.users.findOne({ id: 1 });
let result = await AddNewBetakey(userDoc);
let result = await AddNewInvite(userDoc!);
t.is(result.createdBy, userDoc.id, "Betakey should be created by the requesting user.");
t.is(result.consumed, false, "Betakey should not be consumed.");
t.is(result.createdBy, userDoc!.id, "Invite should be created by the requesting user.");
t.is(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.assert(
Math.abs(result.createdOn - Date.now()) <= 6000,
"Betakey was created roughly now."
"Invite was created roughly now."
);
t.match(
result.betakey,
/^[0-9a-f]{40}$/,
"Betakey should have contents of a 40 character hex string."
);
t.match(result.code, /^[0-9a-f]{40}$/, "Invite code should be a 40 character hex string.");
});
t.end();
+15 -15
View File
@@ -1,7 +1,7 @@
import crypto from "crypto";
import bcrypt from "bcrypt";
import {
BetaKeyDocument,
InviteCodeDocument,
PrivateUserDocument,
PublicAPIKeyDocument,
PublicUserDocument,
@@ -28,7 +28,7 @@ export function CreateAPIKey(): string {
* you know, not do that.
* @returns A string
*/
export function CreateBetaKey(): string {
export function CreateInviteCode(): string {
return crypto.randomBytes(20).toString("hex");
}
@@ -56,14 +56,14 @@ export function AddNewUserAPIKey(
},
};
return db.get<PublicAPIKeyDocument>("public-api-keys").insert(publicApiKeyDoc);
return db["public-api-keys"].insert(publicApiKeyDoc);
}
export function ReinstateBetakey(bkDoc: BetaKeyDocument) {
logger.info(`Reinstated beta key ${bkDoc.betakey}`);
return db.get<BetaKeyDocument>("betakeys").update(
export function ReinstateInvite(inviteDoc: InviteCodeDocument) {
logger.info(`Reinstated Invite ${inviteDoc.code}`);
return db.invites.update(
{
_id: bkDoc._id,
_id: inviteDoc._id,
},
{
$set: {
@@ -73,29 +73,29 @@ export function ReinstateBetakey(bkDoc: BetaKeyDocument) {
);
}
export async function AddNewBetakey(user: PublicUserDocument) {
export async function AddNewInvite(user: PublicUserDocument) {
// beta keys are shorter strings, and it's possible for them to be collided
let betakey = CreateBetaKey();
let code = CreateInviteCode();
let result = await db.get<BetaKeyDocument>("betakeys").insert({
betakey,
let result = await db.invites.insert({
code,
consumed: false,
createdBy: user.id,
createdOn: Date.now(),
});
logger.info(`User ${FormatUserDoc(user)} created a beta key.`);
logger.info(`User ${FormatUserDoc(user)} created an invite.`);
if (!result) {
logger.error(
`Fatal error in creating ${FormatUserDoc(
user
)}'s beta key. Database refused key ${betakey}.`
)}'s invite code. Database refused key ${code}.`
);
throw new Error(
`Fatal error in creating ${FormatUserDoc(
user
)}'s beta key. Database refused key ${betakey}.`
)}'s invite code. Database refused key ${code}.`
);
}
@@ -137,5 +137,5 @@ export async function AddNewUser(
},
};
return db.get<PrivateUserDocument>("users").insert(userDoc);
return db.users.insert(userDoc);
}
+1 -2
View File
@@ -1,7 +1,6 @@
import { GetNextCounterValue } from "./db-core";
import t from "tap";
import db, { CloseConnection } from "../db";
import { CounterDocument } from "kamaitachi-common";
import ResetDBState from "../test-utils/reset-db-state";
t.test("#GetNextCounterValue", (t) => {
@@ -13,7 +12,7 @@ t.test("#GetNextCounterValue", (t) => {
// database starts with this at one
t.is(response, 2, "Counter should return the current number stored");
let dbData = await db.get<CounterDocument>("counters").findOne({
let dbData = await db.counters.findOne({
counterName: "real-counter",
});
+1 -1
View File
@@ -4,7 +4,7 @@ const logger = createLogCtx("db-core.ts");
import { integer } from "../types";
export async function GetNextCounterValue(counterName: string): Promise<integer> {
const sequenceDoc = await db.get("counters").findOneAndUpdate(
const sequenceDoc = await db.counters.findOneAndUpdate(
{
counterName,
},
+4 -4
View File
@@ -14,7 +14,7 @@ const PRIVATE_USER_RETURNS = {
* @returns PublicUserDocument
*/
export function GetUserCaseInsensitive(username: string) {
return db.get<PrivateUserDocument>("users").findOne(
return db.users.findOne(
{
usernameLowercase: username.toLowerCase(),
},
@@ -31,7 +31,7 @@ export function GetUserCaseInsensitive(username: string) {
* @returns PrivateUserDocument
*/
export function PRIVATEINFO_GetUserCaseInsensitive(username: string) {
return db.get<PrivateUserDocument>("users").findOne({
return db.users.findOne({
usernameLowercase: username.toLowerCase(),
});
}
@@ -54,7 +54,7 @@ export function ResolveUser(usernameOrID: string) {
if (usernameOrID.match(/^[0-9]$/)) {
let intID = parseInt(usernameOrID, 10);
return db.get<PrivateUserDocument>("users").findOne(
return db.users.findOne(
{
id: intID,
},
@@ -64,7 +64,7 @@ export function ResolveUser(usernameOrID: string) {
);
}
return db.get<PrivateUserDocument>("users").findOne(
return db.users.findOne(
{
usernameLowercase: usernameOrID,
},
+69 -6
View File
@@ -1,4 +1,25 @@
import monk from "monk";
import {
ChartDocument,
config,
CounterDocument,
FolderDocument,
Game,
GoalDocument,
IIDXBPIData,
ImportDocument,
InviteCodeDocument,
MilestoneDocument,
NotificationDocument,
PrivateUserDocument,
PublicAPIKeyDocument,
ScoreDocument,
SessionDocument,
SongDocument,
UserGoalDocument,
UserMilestoneDocument,
ValidDatabases,
} from "kamaitachi-common";
import monk, { ICollection, IMonkManager } from "monk";
import createLogCtx from "./logger";
const logger = createLogCtx("db.ts");
@@ -12,10 +33,10 @@ if (process.env.NODE_ENV !== "test") {
dbtime = process.hrtime();
}
let db = monk(url);
export let monkDB = monk(url);
if (process.env.NODE_ENV !== "test") {
db.then(() => {
monkDB.then(() => {
let time = process.hrtime(dbtime);
let elapsed = time[0] + time[1] / 1000000;
logger.info(`Database connection successful: took ${elapsed}ms`);
@@ -23,12 +44,54 @@ if (process.env.NODE_ENV !== "test") {
}
export async function ReOpenConnection() {
db = monk(url);
await db.then();
monkDB = monk(url);
await monkDB.then();
}
export async function CloseConnection() {
await db.close();
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"),
};
type GameCollections = Record<Game, ICollection>;
interface GCPartial {
songs: Partial<GameCollections>;
charts: Partial<GameCollections>;
}
interface GameCollectionFull {
songs: GameCollections;
charts: GameCollections;
}
let GameCollectionPartial: GCPartial = {
songs: {},
charts: {},
};
for (const game of config.supportedGames) {
GameCollectionPartial.songs[game] = monkDB.get<SongDocument>(`songs-${game}`);
GameCollectionPartial.charts[game] = monkDB.get<ChartDocument>(`charts-${game}`);
}
// a typescript-friendly interface for the database.
const db = Object.assign(StaticCollections, GameCollectionPartial as GameCollectionFull);
export default db;
+12 -12
View File
@@ -6,7 +6,7 @@ import {
AddNewUserAPIKey,
CreateAPIKey,
PasswordCompare,
ReinstateBetakey,
ReinstateInvite,
ValidatePassword,
} from "../../core/auth-core";
import { ValidateCaptcha } from "../../core/captcha-core";
@@ -98,7 +98,7 @@ router.post(
// username and password match up, we're good to check onwards
let apiKeyDoc = await db.get<PublicAPIKeyDocument>("public-api-keys").findOne({
let apiKeyDoc = await db["public-api-keys"].findOne({
assignedTo: requestedUser.id,
"permissions.selfkey": true,
});
@@ -159,14 +159,14 @@ router.post(
username: Prudence.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/),
password: ValidatePassword,
email: Prudence.regex(LAZY_EMAIL_REGEX),
betakey: "string",
inviteCode: "string",
captcha: "string",
},
{
username:
"Usernames must be between 3 and 20 characters long, and can only contain alphanumeric characters!",
email: "Invalid email.",
betakey: "Invalid beta key.",
inviteCode: "Invalid invite code.",
captcha: "Please fill out the captcha.",
}
),
@@ -200,9 +200,9 @@ router.post(
});
}
let bkeyDoc = await db.get<BetaKeyDocument>("betakeys").findOneAndUpdate(
let inviteCodeDoc = await db.invites.findOneAndUpdate(
{
betakey: req.body.betakey,
code: req.body.inviteCode,
consumed: false,
},
{
@@ -212,15 +212,15 @@ router.post(
}
);
if (!bkeyDoc) {
logger.info(`Invalid betakey given: ${req.body.betakey}.`);
if (!inviteCodeDoc) {
logger.info(`Invalid invite code given: ${req.body.inviteCode}.`);
return res.status(401).json({
success: false,
description: `This beta key is not valid.`,
description: `This invite code is not valid.`,
});
}
logger.info(`Consumed betakey ${bkeyDoc.betakey}.`);
logger.info(`Consumed invite ${inviteCodeDoc.code}.`);
// if we get to this point, We're good to create the user.
@@ -231,7 +231,7 @@ router.post(
`Bailed on user creation ${req.body.username} with betakey ${req.body.betakey}.`
);
await ReinstateBetakey(bkeyDoc);
await ReinstateInvite(inviteCodeDoc);
throw new Error("FATAL in /register - User was created, but refused?");
}
@@ -243,7 +243,7 @@ router.post(
`Bailed on user creation ${req.body.username} with betakey ${req.body.betakey}`
);
await ReinstateBetakey(bkeyDoc);
await ReinstateInvite(inviteCodeDoc);
throw new Error("FATAL in /register - apikey was created, but refused?");
}
@@ -1,14 +1,6 @@
// Chart "Playtype+Difficulty" lookup. This is a common way of resolving Kamaitachi charts.
import {
Difficulties,
Game,
integer,
Playtypes,
IDStrings,
IDString,
ChartDocument,
} from "kamaitachi-common";
import { Difficulties, Game, integer, Playtypes, IDStrings, IDString } from "kamaitachi-common";
import db from "../../db";
export function FindChartWithPTDF<
@@ -17,7 +9,7 @@ export function FindChartWithPTDF<
// @ts-expect-error See kamaitachi-common.
I extends IDStrings = IDString<G, P>
>(game: G, songID: integer, playtype: P, difficulty: Difficulties[I]) {
return db.get<ChartDocument>(`charts-${game}`).findOne({
return db.charts[game].findOne({
songID: songID,
playtype: playtype,
difficulty: difficulty,
@@ -11,7 +11,7 @@ import db from "../../db";
* @returns SongDocument
*/
export function FindSongOnTitleVersion(game: Game, title: string, version: string | number) {
return db.get<SongDocument>(`songs-${game}`).findOne({
return db.songs[game].findOne({
"data.version": version,
$or: [
{
@@ -33,7 +33,7 @@ export function FindSongOnTitleVersion(game: Game, title: string, version: strin
* @returns SongDocument
*/
export function FindSongOnTitle(game: Game, title: string) {
return db.get<SongDocument>(`songs-${game}`).findOne({
return db.songs[game].findOne({
$or: [
{
title: title,
@@ -0,0 +1,78 @@
import {
ChartDocument,
Game,
Playtypes,
ScoreDocument,
SongDocument,
TierlistDataDocument,
} from "kamaitachi-common";
import { DryScore, integer } from "../../../../types";
import { CreateGameSpecific } from "./game-specific";
import { CalculateCHUNITHMRating, CalculateGITADORARating } from "./game-specific-stats";
export async function CreateCalculatedData(
dryScore: DryScore,
chart: ChartDocument,
song: SongDocument
): 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),
]);
return {
rating,
lampRating: 0,
ranking: null,
outOf: null,
gameSpecific,
};
}
/**
* Override the default rating function for a game with
* something else.
*/
const OVERRIDE_RATING_FUNCTIONS = {
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: ChartDocument
) {
// @todo
let OverrideFunction: OverrideRatingFunction | undefined =
// @ts-expect-error I'm too lazy to explain this to TS
OVERRIDE_RATING_FUNCTIONS[game]?.[playtype];
if (!OverrideFunction) {
return 0; // go to default kamaitachi rating fn ?
}
return await OverrideFunction(dryScore, chart);
}
async function CalculateLampRating(
dryScore: DryScore,
chart: ChartDocument,
tierlist: TierlistDataDocument
) {}
interface OverrideRatingFunction {
(dryScore: DryScore, chartData: ChartDocument): number | Promise<number>;
}
@@ -0,0 +1,256 @@
import { ChartDocument, ESDCore, integer } from "kamaitachi-common";
import db from "../../../../db";
import createLogCtx from "../../../../logger";
import { DryScore } from "../../../../types";
const logger = createLogCtx("game-specific-stats.ts");
/**
* Calculates the in-game CHUNITHM rating for a score.
*/
export function CalculateCHUNITHMRating(dryScore: DryScore, chartData: ChartDocument) {
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: ChartDocument) {
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: ChartDocument) {
let scoreCount = await db.get("iidx-eam-scores").count({
chartID: chartData.chartID,
});
if (!scoreCount) {
return null;
}
let worseScores = await db.get("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: ChartDocument) {
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;
}
// cannot be hit?
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: ChartDocument<"sdvx">
) {
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: ChartDocument<"sdvx">
) {
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;
}
@@ -0,0 +1,63 @@
import { ChartDocument, Game, IIDXBPIData, Playtypes } from "kamaitachi-common";
import db from "../../../../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: ChartDocument,
dryScore: DryScore
): 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.notedata.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">,
chart as ChartDocument<"sdvx">
);
gameSpecific.VF5 = CalculateVF5(
dryScore as DryScore<"sdvx">,
chart as ChartDocument<"sdvx">
);
} else if (game === "ddr") {
// either playtype
gameSpecific.MFCP = CalculateMFCP(dryScore, chart);
}
return gameSpecific;
}
@@ -1,5 +1,6 @@
import { ChartDocument, config, integer, ScoreDocument, SongDocument } from "kamaitachi-common";
import { DryScore } from "../../../types";
import { CreateCalculatedData } from "./calculated-data/calculated-data";
/**
* Takes an "intermediate" score and appends the rest of the data it needs.
@@ -13,7 +14,7 @@ export default async function HydrateScore(
song: SongDocument,
scoreID: string
): Promise<ScoreDocument> {
const calculatedData = {} as any; // await CreateCalculatedData(); // @todo
const calculatedData = await CreateCalculatedData(dryScore, chart, song); // @todo
const { scoreData: dryScoreData, ...rest } = dryScore;
@@ -23,6 +23,6 @@ export async function QueueScoreInsert(score: ScoreDocument) {
export async function InsertQueue() {
const temp = ScoreQueue.splice(0);
if (temp.length !== 0) {
await db.get<ScoreDocument>("scores").insert(temp);
await db.scores.insert(temp);
}
}
@@ -1,7 +1,6 @@
import { integer, DryScore } from "../../../types";
import crypto from "crypto";
import db from "../../../db";
import { ScoreDocument } from "kamaitachi-common";
function CreateScoreIDString(userID: integer, partialScore: DryScore, chartID: string) {
const { lamp, grade } = partialScore.scoreData;
@@ -30,7 +29,7 @@ export function CreateScoreID(userID: integer, dryScore: DryScore, chartID: stri
}
export function GetWithScoreID(scoreID: string) {
return db.get<ScoreDocument>("scores").findOne({
return db.scores.findOne({
scoreID,
});
}
@@ -120,7 +120,7 @@ async function HydrateAndInsertScore(
): Promise<ScoreDocument | null> {
const scoreID = CreateScoreID(userID, dryScore, chart.chartID);
const existingScore = await db.get<ScoreDocument>("scores").findOne(
const existingScore = await db.scores.findOne(
{
scoreID,
},
@@ -103,7 +103,7 @@ async function EamScoreConverter(
// esd cannot estimate things below this level of accuracy, so only actually calculate it here
if (percent > 0.1) {
esd = ESDCore.ExpectedSD(config.judgementWindows.iidx[context.playtype], percent);
esd = ESDCore.CalculateESD(config.judgementWindows.iidx[context.playtype], percent);
}
// Now we need to figure out the timestamp for this score.
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +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";
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();
+4 -2
View File
@@ -10,7 +10,8 @@ const DATA_DIR = path.join(__dirname, "./mock-db");
async function ResetState(file: string) {
let filename = path.basename(file, ".json");
await db.get(filename).remove({});
// @ts-expect-error it's right, but we know what we're doing!
await db[filename].remove({});
let fileLoc = path.join(DATA_DIR, file);
let data = JSON.parse(fs.readFileSync(fileLoc, "utf-8"));
@@ -19,7 +20,8 @@ async function ResetState(file: string) {
throw new Error(`Panic, ${filename} not JSONArray?`);
}
await db.get(filename).insert(data);
// @ts-expect-error see above.
await db[filename].insert(data);
}
export default async function ResetDBState() {