mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-13 09:48:27 +03:00
Change all CRLF to LF and standardise on LF
This commit is contained in:
+53
-53
@@ -1,53 +1,53 @@
|
||||
import db from "external/mongo/db";
|
||||
import t from "tap";
|
||||
import ResetDBState from "test-utils/resets";
|
||||
import { DecrementCounterValue, GetNextCounterValue } from "./db";
|
||||
|
||||
t.test("#GetNextCounterValue", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Increments on valid counter hit", async (t) => {
|
||||
const response = await GetNextCounterValue("real-counter");
|
||||
|
||||
// database starts with this at one
|
||||
t.equal(response, 2, "Counter should return the current number stored");
|
||||
|
||||
const dbData = await db.counters.findOne({
|
||||
counterName: "real-counter",
|
||||
});
|
||||
|
||||
t.equal(dbData!.value, 3, "Counter should increment after being hit");
|
||||
});
|
||||
|
||||
t.rejects(
|
||||
() => GetNextCounterValue("fake-counter"),
|
||||
"Could not find sequence document for fake-counter."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#DecrementCounterValue", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should decrease a counter.", async (t) => {
|
||||
await db.counters.insert({ counterName: "foo", value: 3 });
|
||||
|
||||
const res = await DecrementCounterValue("foo");
|
||||
|
||||
t.equal(res, 2);
|
||||
|
||||
const doc = await db.counters.findOne({ counterName: "foo" });
|
||||
|
||||
t.equal(doc?.value, 2);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.rejects(
|
||||
() => GetNextCounterValue("fake-counter"),
|
||||
"Could not find sequence document for fake-counter."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
import db from "external/mongo/db";
|
||||
import t from "tap";
|
||||
import ResetDBState from "test-utils/resets";
|
||||
import { DecrementCounterValue, GetNextCounterValue } from "./db";
|
||||
|
||||
t.test("#GetNextCounterValue", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Increments on valid counter hit", async (t) => {
|
||||
const response = await GetNextCounterValue("real-counter");
|
||||
|
||||
// database starts with this at one
|
||||
t.equal(response, 2, "Counter should return the current number stored");
|
||||
|
||||
const dbData = await db.counters.findOne({
|
||||
counterName: "real-counter",
|
||||
});
|
||||
|
||||
t.equal(dbData!.value, 3, "Counter should increment after being hit");
|
||||
});
|
||||
|
||||
t.rejects(
|
||||
() => GetNextCounterValue("fake-counter"),
|
||||
"Could not find sequence document for fake-counter."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#DecrementCounterValue", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should decrease a counter.", async (t) => {
|
||||
await db.counters.insert({ counterName: "foo", value: 3 });
|
||||
|
||||
const res = await DecrementCounterValue("foo");
|
||||
|
||||
t.equal(res, 2);
|
||||
|
||||
const doc = await db.counters.findOne({ counterName: "foo" });
|
||||
|
||||
t.equal(doc?.value, 2);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.rejects(
|
||||
() => GetNextCounterValue("fake-counter"),
|
||||
"Could not find sequence document for fake-counter."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
+70
-70
@@ -1,70 +1,70 @@
|
||||
import db from "external/mongo/db";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
const logger = CreateLogCtx(__filename);
|
||||
import { integer, Game, ScoreDocument, PBScoreDocument } from "tachi-common";
|
||||
|
||||
export async function GetNextCounterValue(counterName: string): Promise<integer> {
|
||||
const sequenceDoc = await db.counters.findOneAndUpdate(
|
||||
{
|
||||
counterName,
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
// this is marked as deprecated, but it shouldn't be, as returnDocument: "before"
|
||||
// does nothing.
|
||||
returnOriginal: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!sequenceDoc) {
|
||||
logger.error(`Could not find sequence document for ${counterName}`);
|
||||
throw new Error(`Could not find sequence document for ${counterName}.`);
|
||||
}
|
||||
|
||||
return sequenceDoc.value;
|
||||
}
|
||||
|
||||
export async function DecrementCounterValue(counterName: string): Promise<integer> {
|
||||
logger.verbose(`Decrementing Counter Value ${counterName}.`);
|
||||
|
||||
const sequenceDoc = await db.counters.findOneAndUpdate(
|
||||
{
|
||||
counterName,
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
value: -1,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!sequenceDoc) {
|
||||
logger.error(`Could not find sequence document for ${counterName}`);
|
||||
throw new Error(`Could not find sequence document for ${counterName}.`);
|
||||
}
|
||||
|
||||
return sequenceDoc.value;
|
||||
}
|
||||
|
||||
export async function GetRelevantSongsAndCharts(
|
||||
scores: (ScoreDocument | PBScoreDocument)[],
|
||||
game: Game
|
||||
) {
|
||||
const [songs, charts] = await Promise.all([
|
||||
db.songs[game].find({
|
||||
id: { $in: scores.map((e) => e.songID) },
|
||||
}),
|
||||
db.charts[game].find({
|
||||
chartID: { $in: scores.map((e) => e.chartID) },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { songs, charts };
|
||||
}
|
||||
import db from "external/mongo/db";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
const logger = CreateLogCtx(__filename);
|
||||
import { integer, Game, ScoreDocument, PBScoreDocument } from "tachi-common";
|
||||
|
||||
export async function GetNextCounterValue(counterName: string): Promise<integer> {
|
||||
const sequenceDoc = await db.counters.findOneAndUpdate(
|
||||
{
|
||||
counterName,
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
// this is marked as deprecated, but it shouldn't be, as returnDocument: "before"
|
||||
// does nothing.
|
||||
returnOriginal: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!sequenceDoc) {
|
||||
logger.error(`Could not find sequence document for ${counterName}`);
|
||||
throw new Error(`Could not find sequence document for ${counterName}.`);
|
||||
}
|
||||
|
||||
return sequenceDoc.value;
|
||||
}
|
||||
|
||||
export async function DecrementCounterValue(counterName: string): Promise<integer> {
|
||||
logger.verbose(`Decrementing Counter Value ${counterName}.`);
|
||||
|
||||
const sequenceDoc = await db.counters.findOneAndUpdate(
|
||||
{
|
||||
counterName,
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
value: -1,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
if (!sequenceDoc) {
|
||||
logger.error(`Could not find sequence document for ${counterName}`);
|
||||
throw new Error(`Could not find sequence document for ${counterName}.`);
|
||||
}
|
||||
|
||||
return sequenceDoc.value;
|
||||
}
|
||||
|
||||
export async function GetRelevantSongsAndCharts(
|
||||
scores: (ScoreDocument | PBScoreDocument)[],
|
||||
game: Game
|
||||
) {
|
||||
const [songs, charts] = await Promise.all([
|
||||
db.songs[game].find({
|
||||
id: { $in: scores.map((e) => e.songID) },
|
||||
}),
|
||||
db.charts[game].find({
|
||||
chartID: { $in: scores.map((e) => e.chartID) },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { songs, charts };
|
||||
}
|
||||
|
||||
+13
-13
@@ -1,13 +1,13 @@
|
||||
import { Environment } from "lib/setup/config";
|
||||
import nodeFetch from "node-fetch";
|
||||
|
||||
const fetch =
|
||||
Environment.nodeEnv === "test"
|
||||
? () => {
|
||||
throw new Error("Cannot use real fetch inside testing env!");
|
||||
}
|
||||
: nodeFetch;
|
||||
|
||||
export type NodeFetch = (url: RequestInfo, init?: RequestInit | undefined) => Promise<Response>;
|
||||
|
||||
export default fetch as unknown as NodeFetch;
|
||||
import { Environment } from "lib/setup/config";
|
||||
import nodeFetch from "node-fetch";
|
||||
|
||||
const fetch =
|
||||
Environment.nodeEnv === "test"
|
||||
? () => {
|
||||
throw new Error("Cannot use real fetch inside testing env!");
|
||||
}
|
||||
: nodeFetch;
|
||||
|
||||
export type NodeFetch = (url: RequestInfo, init?: RequestInit | undefined) => Promise<Response>;
|
||||
|
||||
export default fetch as unknown as NodeFetch;
|
||||
|
||||
+319
-319
@@ -1,319 +1,319 @@
|
||||
import db from "external/mongo/db";
|
||||
import { FilterQuery } from "mongodb";
|
||||
import {
|
||||
ChartDocument,
|
||||
Difficulties,
|
||||
Game,
|
||||
GPTSupportedVersions,
|
||||
IDStrings,
|
||||
integer,
|
||||
Playtypes,
|
||||
} from "tachi-common";
|
||||
|
||||
export function FindChartWithChartID(game: Game, chartID: string) {
|
||||
return db.charts[game].findOne({ chartID });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find chart with PlaytypeDifficulty. This only finds charts that have `isPrimary` set to true.
|
||||
* If you want to find charts that are not primary, you need to use PTDFVersion.
|
||||
* @see FindChartWithPTDFVersion
|
||||
*/
|
||||
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,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find chart with Playtype, Difficulty and a given version. This does not necessarily return a chart that has
|
||||
* `isPrimary` set.
|
||||
*/
|
||||
export function FindChartWithPTDFVersion<
|
||||
G extends Game = Game,
|
||||
P extends Playtypes[G] = Playtypes[G],
|
||||
I extends IDStrings = IDStrings
|
||||
>(
|
||||
game: G,
|
||||
songID: integer,
|
||||
playtype: P,
|
||||
difficulty: Difficulties[I],
|
||||
version: GPTSupportedVersions[I]
|
||||
) {
|
||||
return db.charts[game].findOne({
|
||||
songID: songID,
|
||||
playtype: playtype,
|
||||
difficulty: difficulty,
|
||||
versions: version,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a DDR Chart based on its "song hash".
|
||||
* Songs in DDR have a consistent checksum-like identifier used on the e-amusement website.
|
||||
* We can use this to locate a chart by combining it with a playtype and difficulty.
|
||||
*
|
||||
* Despite the potentially confusing name of "songHash", songs are NOT meant to store lookup-like tokens.
|
||||
* This is just for simplification reasons.
|
||||
* @param songHash The identifier for the song.
|
||||
* @param playtype The playtype for the chart.
|
||||
* @param difficulty The difficulty for the chart.
|
||||
*/
|
||||
export function FindDDRChartOnSongHash(
|
||||
songHash: string,
|
||||
// Technically both of these should be "ddr" instead of Game, but it proves hard to work with.
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings]
|
||||
) {
|
||||
// note: this only works on accident because monk
|
||||
// allows any strings like "foo.bar".
|
||||
// We *should* normally cast this to ChartDocument<"ddr:SP" | "ddr:DP">
|
||||
return db.charts.ddr.findOne({
|
||||
"data.songHash": songHash,
|
||||
playtype,
|
||||
difficulty,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a BMS chart on either its md5sum or its sha256sum.
|
||||
* @param hash The md5 or sha256 hash to look for.
|
||||
*/
|
||||
export function FindBMSChartOnHash(hash: string) {
|
||||
return db.charts.bms.findOne({
|
||||
$or: [{ "data.hashMD5": hash }, { "data.hashSHA256": hash }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a chart on its in-game-ID, playtype and difficulty.
|
||||
*/
|
||||
export function FindChartOnInGameID(
|
||||
game: Game,
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings]
|
||||
) {
|
||||
if (game === "bms" || game === "usc") {
|
||||
throw new Error(`Cannot call FindChartOnInGameID for game ${game}.`);
|
||||
}
|
||||
|
||||
return db.charts[game].findOne({
|
||||
"data.inGameID": inGameID,
|
||||
playtype,
|
||||
difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a non-custom chart on its in-game-ID, playtype and difficulty.
|
||||
* This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate.
|
||||
*/
|
||||
export function FindIIDXChartOnInGameID(
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings]
|
||||
) {
|
||||
return db.charts.iidx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
"data.2dxtraSet": null,
|
||||
isPrimary: true,
|
||||
playtype,
|
||||
difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a non-custom chart on its in-game-ID, playtype and difficulty.
|
||||
* This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate.
|
||||
*/
|
||||
export function FindIIDXChartOnInGameIDVersion(
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings],
|
||||
version: GPTSupportedVersions[IDStrings]
|
||||
) {
|
||||
return db.charts.iidx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
"data.2dxtraSet": null,
|
||||
playtype,
|
||||
difficulty,
|
||||
versions: version,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a chart on its in-game-ID, playtype, difficulty and version.
|
||||
*/
|
||||
export function FindChartOnInGameIDVersion<I extends IDStrings = IDStrings>(
|
||||
game: Game,
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[I],
|
||||
version: GPTSupportedVersions[I]
|
||||
) {
|
||||
return db.charts[game].findOne({
|
||||
"data.inGameID": inGameID,
|
||||
versions: version,
|
||||
playtype,
|
||||
difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an IIDX chart on its 2dxtra hash, which is the sha256 of the .1 buffer.
|
||||
*/
|
||||
export function FindIIDXChartWith2DXtraHash(hash: string) {
|
||||
return db.charts.iidx.findOne({
|
||||
"data.hashSHA256": hash,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an SDVX Chart on its in game ID. This exists to handle
|
||||
* oddities with SDVX difficulties - If "ANY_INF" is sent, it actually
|
||||
* refers to any of INF, GRV, HVN or VVD. This is because some services treat
|
||||
* all of those as the same difficulty, but we do not.
|
||||
*/
|
||||
export function FindSDVXChartOnInGameID(
|
||||
inGameID: number,
|
||||
difficulty: Difficulties["sdvx:Single"] | "ANY_INF"
|
||||
) {
|
||||
const diffQuery =
|
||||
difficulty === "ANY_INF"
|
||||
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
|
||||
: difficulty;
|
||||
|
||||
return db.charts.sdvx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
difficulty: diffQuery,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function FindSDVXChartOnInGameIDVersion(
|
||||
inGameID: number,
|
||||
difficulty: "NOV" | "ADV" | "EXH" | "MXM" | "ANY_INF",
|
||||
version: GPTSupportedVersions["sdvx:Single"]
|
||||
) {
|
||||
const diffQuery =
|
||||
difficulty === "ANY_INF"
|
||||
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
|
||||
: difficulty;
|
||||
|
||||
return db.charts.sdvx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
difficulty: diffQuery,
|
||||
versions: version,
|
||||
});
|
||||
}
|
||||
|
||||
export function FindSDVXChartOnDFVersion(
|
||||
songID: integer,
|
||||
difficulty: "NOV" | "ADV" | "EXH" | "MXM" | "ANY_INF",
|
||||
version: GPTSupportedVersions["sdvx:Single"]
|
||||
) {
|
||||
const diffQuery =
|
||||
difficulty === "ANY_INF"
|
||||
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
|
||||
: difficulty;
|
||||
|
||||
return db.charts.sdvx.findOne({
|
||||
songID,
|
||||
difficulty: diffQuery,
|
||||
versions: version,
|
||||
});
|
||||
}
|
||||
|
||||
export function FindChartOnSHA256(game: Game, hash: string) {
|
||||
if (game !== "bms" && game !== "usc" && game !== "iidx") {
|
||||
throw new Error(`Cannot call FindChartOnSHA256 for game ${game}.`);
|
||||
}
|
||||
|
||||
return db.charts[game].findOne({
|
||||
"data.hashSHA256": hash,
|
||||
});
|
||||
}
|
||||
|
||||
export function FindChartOnARCID(game: "iidx" | "ddr" | "jubeat" | "sdvx", arcID: string) {
|
||||
return db.charts[game].findOne({
|
||||
"data.arcChartID": arcID,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the N most popular charts for this game + playtype.
|
||||
* Popularity is determined by how many scores match in the score
|
||||
* collection.
|
||||
*/
|
||||
export async function FindChartsOnPopularity(
|
||||
game: Game,
|
||||
playtype: Playtypes[Game],
|
||||
songIDs?: integer[],
|
||||
skip = 0,
|
||||
limit = 100,
|
||||
scoreCollection: "personal-bests" | "scores" = "personal-bests"
|
||||
): Promise<(ChartDocument & { __playcount: integer })[]> {
|
||||
const matchQuery: FilterQuery<ChartDocument> = {
|
||||
playtype,
|
||||
};
|
||||
|
||||
if (songIDs) {
|
||||
matchQuery.songID = { $in: songIDs };
|
||||
}
|
||||
|
||||
// MongoDB is a hard beast to wield.
|
||||
// This code might look very inefficient, but originally this *was*
|
||||
// a single aggregate pipeline.
|
||||
//
|
||||
// We've split it up into multiple queries as this is an order of
|
||||
// magnitude faster.
|
||||
// Not entirely sure why, but $lookup is incredibly inefficient,
|
||||
// and you should just avoid it.
|
||||
const charts = (await db.charts[game].find(matchQuery)) as unknown as (ChartDocument & {
|
||||
__playcount: integer;
|
||||
})[];
|
||||
|
||||
const scoreCounts = (await db[scoreCollection].aggregate([
|
||||
{
|
||||
$match: { chartID: { $in: charts.map((e) => e.chartID) } },
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$chartID",
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
count: -1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$skip: skip,
|
||||
},
|
||||
{
|
||||
$limit: limit,
|
||||
},
|
||||
])) as { _id: string; count: integer }[];
|
||||
|
||||
const scoreCountMap = new Map();
|
||||
|
||||
for (const sc of scoreCounts) {
|
||||
scoreCountMap.set(sc._id, sc.count);
|
||||
}
|
||||
|
||||
for (const chart of charts) {
|
||||
chart.__playcount = scoreCountMap.get(chart.chartID) ?? 0;
|
||||
}
|
||||
|
||||
return charts.sort((a, b) => b.__playcount - a.__playcount).slice(skip, skip + limit);
|
||||
}
|
||||
import db from "external/mongo/db";
|
||||
import { FilterQuery } from "mongodb";
|
||||
import {
|
||||
ChartDocument,
|
||||
Difficulties,
|
||||
Game,
|
||||
GPTSupportedVersions,
|
||||
IDStrings,
|
||||
integer,
|
||||
Playtypes,
|
||||
} from "tachi-common";
|
||||
|
||||
export function FindChartWithChartID(game: Game, chartID: string) {
|
||||
return db.charts[game].findOne({ chartID });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find chart with PlaytypeDifficulty. This only finds charts that have `isPrimary` set to true.
|
||||
* If you want to find charts that are not primary, you need to use PTDFVersion.
|
||||
* @see FindChartWithPTDFVersion
|
||||
*/
|
||||
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,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find chart with Playtype, Difficulty and a given version. This does not necessarily return a chart that has
|
||||
* `isPrimary` set.
|
||||
*/
|
||||
export function FindChartWithPTDFVersion<
|
||||
G extends Game = Game,
|
||||
P extends Playtypes[G] = Playtypes[G],
|
||||
I extends IDStrings = IDStrings
|
||||
>(
|
||||
game: G,
|
||||
songID: integer,
|
||||
playtype: P,
|
||||
difficulty: Difficulties[I],
|
||||
version: GPTSupportedVersions[I]
|
||||
) {
|
||||
return db.charts[game].findOne({
|
||||
songID: songID,
|
||||
playtype: playtype,
|
||||
difficulty: difficulty,
|
||||
versions: version,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a DDR Chart based on its "song hash".
|
||||
* Songs in DDR have a consistent checksum-like identifier used on the e-amusement website.
|
||||
* We can use this to locate a chart by combining it with a playtype and difficulty.
|
||||
*
|
||||
* Despite the potentially confusing name of "songHash", songs are NOT meant to store lookup-like tokens.
|
||||
* This is just for simplification reasons.
|
||||
* @param songHash The identifier for the song.
|
||||
* @param playtype The playtype for the chart.
|
||||
* @param difficulty The difficulty for the chart.
|
||||
*/
|
||||
export function FindDDRChartOnSongHash(
|
||||
songHash: string,
|
||||
// Technically both of these should be "ddr" instead of Game, but it proves hard to work with.
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings]
|
||||
) {
|
||||
// note: this only works on accident because monk
|
||||
// allows any strings like "foo.bar".
|
||||
// We *should* normally cast this to ChartDocument<"ddr:SP" | "ddr:DP">
|
||||
return db.charts.ddr.findOne({
|
||||
"data.songHash": songHash,
|
||||
playtype,
|
||||
difficulty,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a BMS chart on either its md5sum or its sha256sum.
|
||||
* @param hash The md5 or sha256 hash to look for.
|
||||
*/
|
||||
export function FindBMSChartOnHash(hash: string) {
|
||||
return db.charts.bms.findOne({
|
||||
$or: [{ "data.hashMD5": hash }, { "data.hashSHA256": hash }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a chart on its in-game-ID, playtype and difficulty.
|
||||
*/
|
||||
export function FindChartOnInGameID(
|
||||
game: Game,
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings]
|
||||
) {
|
||||
if (game === "bms" || game === "usc") {
|
||||
throw new Error(`Cannot call FindChartOnInGameID for game ${game}.`);
|
||||
}
|
||||
|
||||
return db.charts[game].findOne({
|
||||
"data.inGameID": inGameID,
|
||||
playtype,
|
||||
difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a non-custom chart on its in-game-ID, playtype and difficulty.
|
||||
* This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate.
|
||||
*/
|
||||
export function FindIIDXChartOnInGameID(
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings]
|
||||
) {
|
||||
return db.charts.iidx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
"data.2dxtraSet": null,
|
||||
isPrimary: true,
|
||||
playtype,
|
||||
difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a non-custom chart on its in-game-ID, playtype and difficulty.
|
||||
* This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate.
|
||||
*/
|
||||
export function FindIIDXChartOnInGameIDVersion(
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings],
|
||||
version: GPTSupportedVersions[IDStrings]
|
||||
) {
|
||||
return db.charts.iidx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
"data.2dxtraSet": null,
|
||||
playtype,
|
||||
difficulty,
|
||||
versions: version,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a chart on its in-game-ID, playtype, difficulty and version.
|
||||
*/
|
||||
export function FindChartOnInGameIDVersion<I extends IDStrings = IDStrings>(
|
||||
game: Game,
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[I],
|
||||
version: GPTSupportedVersions[I]
|
||||
) {
|
||||
return db.charts[game].findOne({
|
||||
"data.inGameID": inGameID,
|
||||
versions: version,
|
||||
playtype,
|
||||
difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an IIDX chart on its 2dxtra hash, which is the sha256 of the .1 buffer.
|
||||
*/
|
||||
export function FindIIDXChartWith2DXtraHash(hash: string) {
|
||||
return db.charts.iidx.findOne({
|
||||
"data.hashSHA256": hash,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an SDVX Chart on its in game ID. This exists to handle
|
||||
* oddities with SDVX difficulties - If "ANY_INF" is sent, it actually
|
||||
* refers to any of INF, GRV, HVN or VVD. This is because some services treat
|
||||
* all of those as the same difficulty, but we do not.
|
||||
*/
|
||||
export function FindSDVXChartOnInGameID(
|
||||
inGameID: number,
|
||||
difficulty: Difficulties["sdvx:Single"] | "ANY_INF"
|
||||
) {
|
||||
const diffQuery =
|
||||
difficulty === "ANY_INF"
|
||||
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
|
||||
: difficulty;
|
||||
|
||||
return db.charts.sdvx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
difficulty: diffQuery,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function FindSDVXChartOnInGameIDVersion(
|
||||
inGameID: number,
|
||||
difficulty: "NOV" | "ADV" | "EXH" | "MXM" | "ANY_INF",
|
||||
version: GPTSupportedVersions["sdvx:Single"]
|
||||
) {
|
||||
const diffQuery =
|
||||
difficulty === "ANY_INF"
|
||||
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
|
||||
: difficulty;
|
||||
|
||||
return db.charts.sdvx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
difficulty: diffQuery,
|
||||
versions: version,
|
||||
});
|
||||
}
|
||||
|
||||
export function FindSDVXChartOnDFVersion(
|
||||
songID: integer,
|
||||
difficulty: "NOV" | "ADV" | "EXH" | "MXM" | "ANY_INF",
|
||||
version: GPTSupportedVersions["sdvx:Single"]
|
||||
) {
|
||||
const diffQuery =
|
||||
difficulty === "ANY_INF"
|
||||
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
|
||||
: difficulty;
|
||||
|
||||
return db.charts.sdvx.findOne({
|
||||
songID,
|
||||
difficulty: diffQuery,
|
||||
versions: version,
|
||||
});
|
||||
}
|
||||
|
||||
export function FindChartOnSHA256(game: Game, hash: string) {
|
||||
if (game !== "bms" && game !== "usc" && game !== "iidx") {
|
||||
throw new Error(`Cannot call FindChartOnSHA256 for game ${game}.`);
|
||||
}
|
||||
|
||||
return db.charts[game].findOne({
|
||||
"data.hashSHA256": hash,
|
||||
});
|
||||
}
|
||||
|
||||
export function FindChartOnARCID(game: "iidx" | "ddr" | "jubeat" | "sdvx", arcID: string) {
|
||||
return db.charts[game].findOne({
|
||||
"data.arcChartID": arcID,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the N most popular charts for this game + playtype.
|
||||
* Popularity is determined by how many scores match in the score
|
||||
* collection.
|
||||
*/
|
||||
export async function FindChartsOnPopularity(
|
||||
game: Game,
|
||||
playtype: Playtypes[Game],
|
||||
songIDs?: integer[],
|
||||
skip = 0,
|
||||
limit = 100,
|
||||
scoreCollection: "personal-bests" | "scores" = "personal-bests"
|
||||
): Promise<(ChartDocument & { __playcount: integer })[]> {
|
||||
const matchQuery: FilterQuery<ChartDocument> = {
|
||||
playtype,
|
||||
};
|
||||
|
||||
if (songIDs) {
|
||||
matchQuery.songID = { $in: songIDs };
|
||||
}
|
||||
|
||||
// MongoDB is a hard beast to wield.
|
||||
// This code might look very inefficient, but originally this *was*
|
||||
// a single aggregate pipeline.
|
||||
//
|
||||
// We've split it up into multiple queries as this is an order of
|
||||
// magnitude faster.
|
||||
// Not entirely sure why, but $lookup is incredibly inefficient,
|
||||
// and you should just avoid it.
|
||||
const charts = (await db.charts[game].find(matchQuery)) as unknown as (ChartDocument & {
|
||||
__playcount: integer;
|
||||
})[];
|
||||
|
||||
const scoreCounts = (await db[scoreCollection].aggregate([
|
||||
{
|
||||
$match: { chartID: { $in: charts.map((e) => e.chartID) } },
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$chartID",
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
$sort: {
|
||||
count: -1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$skip: skip,
|
||||
},
|
||||
{
|
||||
$limit: limit,
|
||||
},
|
||||
])) as { _id: string; count: integer }[];
|
||||
|
||||
const scoreCountMap = new Map();
|
||||
|
||||
for (const sc of scoreCounts) {
|
||||
scoreCountMap.set(sc._id, sc.count);
|
||||
}
|
||||
|
||||
for (const chart of charts) {
|
||||
chart.__playcount = scoreCountMap.get(chart.chartID) ?? 0;
|
||||
}
|
||||
|
||||
return charts.sort((a, b) => b.__playcount - a.__playcount).slice(skip, skip + limit);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
import db from "external/mongo/db";
|
||||
import { KtLogger } from "lib/logger/logger";
|
||||
import { InternalFailure } from "lib/score-import/framework/common/converter-failures";
|
||||
import { FindOneResult } from "monk";
|
||||
import { Game, integer, SongDocument } from "tachi-common";
|
||||
import { EscapeStringRegexp } from "../misc";
|
||||
|
||||
/**
|
||||
* 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): Promise<FindOneResult<SongDocument>> {
|
||||
// @optimisable: Performance should be tested here by having a utility field for all-titles.
|
||||
return db.songs[game].findOne({
|
||||
$or: [
|
||||
{
|
||||
title: title,
|
||||
},
|
||||
{
|
||||
altTitles: title,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a song on a song title case-insensitively.
|
||||
* This is needed for services that provide horrifically mutated string titles.
|
||||
*/
|
||||
export function FindSongOnTitleInsensitive(
|
||||
game: Game,
|
||||
title: string
|
||||
): Promise<FindOneResult<SongDocument>> {
|
||||
// @optimisable: Performance should be tested here by having a utility field for all-titles.
|
||||
|
||||
const regex = new RegExp(`^${EscapeStringRegexp(title)}$`, "iu");
|
||||
return db.songs[game].findOne({
|
||||
$or: [
|
||||
{
|
||||
title: { $regex: regex },
|
||||
},
|
||||
{
|
||||
altTitles: { $regex: regex },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a song document based on the Tachi songID. Depending on the database this might
|
||||
* also be the in-game-ID.
|
||||
* @param game - The game to search upon.
|
||||
* @param songID - The song ID to match.
|
||||
* @returns SongDocument
|
||||
*/
|
||||
export function FindSongOnID(game: Game, songID: integer): Promise<FindOneResult<SongDocument>> {
|
||||
return db.songs[game].findOne({
|
||||
id: songID,
|
||||
});
|
||||
}
|
||||
|
||||
export async function FindSongOnIDGuaranteed(game: Game, songID: integer, logger: KtLogger) {
|
||||
const song = await FindSongOnID(game, songID);
|
||||
|
||||
if (!song) {
|
||||
logger.severe(`Song-Chart desync for ${songID}. Has charts, but no song.`);
|
||||
throw new InternalFailure(`Song-Chart desync for ${songID}. Has charts, but no song.`);
|
||||
}
|
||||
|
||||
return song;
|
||||
}
|
||||
import db from "external/mongo/db";
|
||||
import { KtLogger } from "lib/logger/logger";
|
||||
import { InternalFailure } from "lib/score-import/framework/common/converter-failures";
|
||||
import { FindOneResult } from "monk";
|
||||
import { Game, integer, SongDocument } from "tachi-common";
|
||||
import { EscapeStringRegexp } from "../misc";
|
||||
|
||||
/**
|
||||
* 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): Promise<FindOneResult<SongDocument>> {
|
||||
// @optimisable: Performance should be tested here by having a utility field for all-titles.
|
||||
return db.songs[game].findOne({
|
||||
$or: [
|
||||
{
|
||||
title: title,
|
||||
},
|
||||
{
|
||||
altTitles: title,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a song on a song title case-insensitively.
|
||||
* This is needed for services that provide horrifically mutated string titles.
|
||||
*/
|
||||
export function FindSongOnTitleInsensitive(
|
||||
game: Game,
|
||||
title: string
|
||||
): Promise<FindOneResult<SongDocument>> {
|
||||
// @optimisable: Performance should be tested here by having a utility field for all-titles.
|
||||
|
||||
const regex = new RegExp(`^${EscapeStringRegexp(title)}$`, "iu");
|
||||
return db.songs[game].findOne({
|
||||
$or: [
|
||||
{
|
||||
title: { $regex: regex },
|
||||
},
|
||||
{
|
||||
altTitles: { $regex: regex },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a song document based on the Tachi songID. Depending on the database this might
|
||||
* also be the in-game-ID.
|
||||
* @param game - The game to search upon.
|
||||
* @param songID - The song ID to match.
|
||||
* @returns SongDocument
|
||||
*/
|
||||
export function FindSongOnID(game: Game, songID: integer): Promise<FindOneResult<SongDocument>> {
|
||||
return db.songs[game].findOne({
|
||||
id: songID,
|
||||
});
|
||||
}
|
||||
|
||||
export async function FindSongOnIDGuaranteed(game: Game, songID: integer, logger: KtLogger) {
|
||||
const song = await FindSongOnID(game, songID);
|
||||
|
||||
if (!song) {
|
||||
logger.severe(`Song-Chart desync for ${songID}. Has charts, but no song.`);
|
||||
throw new InternalFailure(`Song-Chart desync for ${songID}. Has charts, but no song.`);
|
||||
}
|
||||
|
||||
return song;
|
||||
}
|
||||
|
||||
+67
-67
@@ -1,67 +1,67 @@
|
||||
import {
|
||||
FolderDocument,
|
||||
TableDocument,
|
||||
SessionDocument,
|
||||
ScoreDocument,
|
||||
ChartDocument,
|
||||
PublicUserDocument,
|
||||
UserGameStats,
|
||||
Game,
|
||||
Playtypes,
|
||||
SongDocument,
|
||||
UserSettings,
|
||||
TachiAPIClientDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
declare module "express-session" {
|
||||
// Inject additional properties on express-session
|
||||
interface SessionData {
|
||||
tachi: TachiSessionData;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TachiSessionData {
|
||||
user: PublicUserDocument;
|
||||
settings: UserSettings;
|
||||
}
|
||||
|
||||
export interface TachiAPIFailResponse {
|
||||
success: false;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TachiAPISuccessResponse {
|
||||
success: true;
|
||||
description: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TachiAPIReponse = TachiAPIFailResponse | TachiAPISuccessResponse;
|
||||
|
||||
/**
|
||||
* Clarity type for empty objects - such as in context.
|
||||
*/
|
||||
export type EmptyObject = Record<string, never>;
|
||||
|
||||
/**
|
||||
* Data that may be monkey-patched onto req.tachi. This holds things such as middleware results.
|
||||
*/
|
||||
export interface TachiRequestData {
|
||||
uscChartDoc?: ChartDocument<"usc:Controller" | "usc:Keyboard">;
|
||||
|
||||
beatorajaChartDoc?: ChartDocument<"bms:7K" | "bms:14K">;
|
||||
|
||||
requestedUser?: PublicUserDocument;
|
||||
requestedUserGameStats?: UserGameStats;
|
||||
game?: Game;
|
||||
playtype?: Playtypes[Game];
|
||||
|
||||
chartDoc?: ChartDocument;
|
||||
songDoc?: SongDocument;
|
||||
scoreDoc?: ScoreDocument;
|
||||
sessionDoc?: SessionDocument;
|
||||
tableDoc?: TableDocument;
|
||||
folderDoc?: FolderDocument;
|
||||
|
||||
apiClientDoc: Omit<TachiAPIClientDocument, "clientSecret">;
|
||||
}
|
||||
import {
|
||||
FolderDocument,
|
||||
TableDocument,
|
||||
SessionDocument,
|
||||
ScoreDocument,
|
||||
ChartDocument,
|
||||
PublicUserDocument,
|
||||
UserGameStats,
|
||||
Game,
|
||||
Playtypes,
|
||||
SongDocument,
|
||||
UserSettings,
|
||||
TachiAPIClientDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
declare module "express-session" {
|
||||
// Inject additional properties on express-session
|
||||
interface SessionData {
|
||||
tachi: TachiSessionData;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TachiSessionData {
|
||||
user: PublicUserDocument;
|
||||
settings: UserSettings;
|
||||
}
|
||||
|
||||
export interface TachiAPIFailResponse {
|
||||
success: false;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TachiAPISuccessResponse {
|
||||
success: true;
|
||||
description: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TachiAPIReponse = TachiAPIFailResponse | TachiAPISuccessResponse;
|
||||
|
||||
/**
|
||||
* Clarity type for empty objects - such as in context.
|
||||
*/
|
||||
export type EmptyObject = Record<string, never>;
|
||||
|
||||
/**
|
||||
* Data that may be monkey-patched onto req.tachi. This holds things such as middleware results.
|
||||
*/
|
||||
export interface TachiRequestData {
|
||||
uscChartDoc?: ChartDocument<"usc:Controller" | "usc:Keyboard">;
|
||||
|
||||
beatorajaChartDoc?: ChartDocument<"bms:7K" | "bms:14K">;
|
||||
|
||||
requestedUser?: PublicUserDocument;
|
||||
requestedUserGameStats?: UserGameStats;
|
||||
game?: Game;
|
||||
playtype?: Playtypes[Game];
|
||||
|
||||
chartDoc?: ChartDocument;
|
||||
songDoc?: SongDocument;
|
||||
scoreDoc?: ScoreDocument;
|
||||
sessionDoc?: SessionDocument;
|
||||
tableDoc?: TableDocument;
|
||||
folderDoc?: FolderDocument;
|
||||
|
||||
apiClientDoc: Omit<TachiAPIClientDocument, "clientSecret">;
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
import t from "tap";
|
||||
import { DatabaseSchemas } from "external/mongo/schemas";
|
||||
import { prAssert } from "test-utils/asserts";
|
||||
import { GetUserCaseInsensitive, FormatUserDoc } from "./user";
|
||||
import { PublicUserDocument } from "tachi-common";
|
||||
|
||||
import ResetDBState from "test-utils/resets";
|
||||
|
||||
t.test("#GetUserCaseInsensitive", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the user for an exact username", async (t) => {
|
||||
const result = await GetUserCaseInsensitive("test_zkldi");
|
||||
|
||||
t.not(result, null, "Should not return null");
|
||||
|
||||
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
|
||||
|
||||
t.ok(DatabaseSchemas.users(result), "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) => {
|
||||
const result = await GetUserCaseInsensitive("tesT_ZkLdi");
|
||||
|
||||
t.not(result, null, "Should not return null");
|
||||
|
||||
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
|
||||
|
||||
t.ok(DatabaseSchemas.users(result), "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) => {
|
||||
const result = await GetUserCaseInsensitive("foobar");
|
||||
|
||||
t.equal(result, null, "Should return null");
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
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 t from "tap";
|
||||
import { DatabaseSchemas } from "external/mongo/schemas";
|
||||
import { prAssert } from "test-utils/asserts";
|
||||
import { GetUserCaseInsensitive, FormatUserDoc } from "./user";
|
||||
import { PublicUserDocument } from "tachi-common";
|
||||
|
||||
import ResetDBState from "test-utils/resets";
|
||||
|
||||
t.test("#GetUserCaseInsensitive", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the user for an exact username", async (t) => {
|
||||
const result = await GetUserCaseInsensitive("test_zkldi");
|
||||
|
||||
t.not(result, null, "Should not return null");
|
||||
|
||||
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
|
||||
|
||||
t.ok(DatabaseSchemas.users(result), "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) => {
|
||||
const result = await GetUserCaseInsensitive("tesT_ZkLdi");
|
||||
|
||||
t.not(result, null, "Should not return null");
|
||||
|
||||
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
|
||||
|
||||
t.ok(DatabaseSchemas.users(result), "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) => {
|
||||
const result = await GetUserCaseInsensitive("foobar");
|
||||
|
||||
t.equal(result, null, "Should return null");
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
+224
-224
@@ -1,224 +1,224 @@
|
||||
import {
|
||||
integer,
|
||||
PublicUserDocument,
|
||||
UserGameStats,
|
||||
GetGamePTConfig,
|
||||
Game,
|
||||
Playtypes,
|
||||
UGSRatingsLookup,
|
||||
IDStrings,
|
||||
} from "tachi-common";
|
||||
import { FindOneResult } from "monk";
|
||||
import db from "external/mongo/db";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
/**
|
||||
* Returns a user's username from their ID. Throws if no user with that ID exists.
|
||||
*/
|
||||
export async function GetUsernameFromUserID(userID: integer): Promise<string> {
|
||||
const partialDoc = await db.users.findOne(
|
||||
{
|
||||
id: userID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
username: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!partialDoc) {
|
||||
throw new Error(`Could not find username for userID ${userID}.`);
|
||||
}
|
||||
|
||||
return partialDoc.username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user based on their username case-insensitively.
|
||||
*/
|
||||
export function GetUserCaseInsensitive(
|
||||
username: string
|
||||
): Promise<FindOneResult<PublicUserDocument>> {
|
||||
return db.users.findOne({
|
||||
usernameLowercase: username.toLowerCase(),
|
||||
}) as Promise<FindOneResult<PublicUserDocument>>;
|
||||
}
|
||||
|
||||
export async function CheckIfEmailInUse(email: string) {
|
||||
const doc = await db["user-private-information"].findOne({ email });
|
||||
|
||||
return !!doc;
|
||||
}
|
||||
|
||||
export function GetUserPrivateInfo(userID: integer) {
|
||||
return db["user-private-information"].findOne({ userID });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user from their userID.
|
||||
*/
|
||||
export function GetUserWithID(userID: integer): Promise<FindOneResult<PublicUserDocument>> {
|
||||
return db.users.findOne({
|
||||
id: userID,
|
||||
}) as Promise<FindOneResult<PublicUserDocument>>;
|
||||
}
|
||||
|
||||
export function GetSettingsForUser(userID: integer) {
|
||||
return db["user-settings"].findOne({
|
||||
userID: userID,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the users for these user IDs.
|
||||
*/
|
||||
export function GetUsersWithIDs(userIDs: integer[]) {
|
||||
return db.users.find({
|
||||
id: { $in: userIDs },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user document that is expected to exist.
|
||||
* If the user document is not found, a severe error is logged, and this
|
||||
* function throws.
|
||||
*/
|
||||
export async function GetUserWithIDGuaranteed(userID: integer): Promise<PublicUserDocument> {
|
||||
const userDoc = await GetUserWithID(userID);
|
||||
|
||||
if (!userDoc) {
|
||||
logger.severe(
|
||||
`User ${userID} does not have an associated user document, but one was expected.`
|
||||
);
|
||||
throw new Error(
|
||||
`User ${userID} does not have an associated user document, but one was expected.`
|
||||
);
|
||||
}
|
||||
|
||||
return userDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function ResolveUser(usernameOrID: string) {
|
||||
// user ID passed
|
||||
if (usernameOrID.match(/^[0-9]+$/u)) {
|
||||
const intID = Number(usernameOrID);
|
||||
|
||||
return GetUserWithID(intID);
|
||||
}
|
||||
|
||||
return GetUserCaseInsensitive(usernameOrID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a formatted string indicating the user. This is used for logging.
|
||||
*/
|
||||
export function FormatUserDoc(userdoc: PublicUserDocument) {
|
||||
return `${userdoc.username} (#${userdoc.id})`;
|
||||
}
|
||||
|
||||
export async function GetUsersRanking(stats: UserGameStats) {
|
||||
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
|
||||
|
||||
const aggRes = await db["game-stats"].aggregate([
|
||||
{
|
||||
$match: {
|
||||
game: stats.game,
|
||||
playtype: stats.playtype,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
ranking: {
|
||||
$sum: {
|
||||
$cond: {
|
||||
if: {
|
||||
$gt: [
|
||||
`$ratings.${gptConfig.defaultProfileRatingAlg}`,
|
||||
stats.ratings[gptConfig.defaultProfileRatingAlg],
|
||||
],
|
||||
},
|
||||
then: 1,
|
||||
else: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return (aggRes[0].ranking + 1) as integer;
|
||||
}
|
||||
|
||||
export function GetUGPTPlaycount(userID: integer, game: Game, playtype: Playtypes[Game]) {
|
||||
return db.scores.count({ userID, game, playtype });
|
||||
}
|
||||
|
||||
export async function GetAllRankings(stats: UserGameStats) {
|
||||
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
|
||||
|
||||
const entries = await Promise.all(
|
||||
gptConfig.profileRatingAlgs.map((k) =>
|
||||
GetUsersRankingAndOutOf(stats, k).then((r) => [k, r])
|
||||
)
|
||||
);
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export async function GetUsersRankingAndOutOf(
|
||||
stats: UserGameStats,
|
||||
alg?: UGSRatingsLookup[IDStrings]
|
||||
) {
|
||||
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
|
||||
|
||||
const ratingAlg = alg ?? gptConfig.defaultProfileRatingAlg;
|
||||
|
||||
const aggRes = await db["game-stats"].aggregate([
|
||||
{
|
||||
$match: {
|
||||
game: stats.game,
|
||||
playtype: stats.playtype,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
outOf: { $sum: 1 },
|
||||
ranking: {
|
||||
$sum: {
|
||||
$cond: {
|
||||
if: {
|
||||
$gt: [`$ratings.${ratingAlg}`, stats.ratings[ratingAlg]],
|
||||
},
|
||||
then: 1,
|
||||
else: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
ranking: (aggRes[0].ranking + 1) as integer,
|
||||
outOf: aggRes[0].outOf as integer,
|
||||
};
|
||||
}
|
||||
|
||||
const FIVE_MINUTES = 1000 * 60 * 5;
|
||||
|
||||
/**
|
||||
* Returns the cutoff point for "being online" in tachi. This means the user
|
||||
* has made any page request in the past 5 minutes.
|
||||
*/
|
||||
export function GetOnlineCutoff() {
|
||||
return Date.now() - FIVE_MINUTES;
|
||||
}
|
||||
import {
|
||||
integer,
|
||||
PublicUserDocument,
|
||||
UserGameStats,
|
||||
GetGamePTConfig,
|
||||
Game,
|
||||
Playtypes,
|
||||
UGSRatingsLookup,
|
||||
IDStrings,
|
||||
} from "tachi-common";
|
||||
import { FindOneResult } from "monk";
|
||||
import db from "external/mongo/db";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
/**
|
||||
* Returns a user's username from their ID. Throws if no user with that ID exists.
|
||||
*/
|
||||
export async function GetUsernameFromUserID(userID: integer): Promise<string> {
|
||||
const partialDoc = await db.users.findOne(
|
||||
{
|
||||
id: userID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
username: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!partialDoc) {
|
||||
throw new Error(`Could not find username for userID ${userID}.`);
|
||||
}
|
||||
|
||||
return partialDoc.username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user based on their username case-insensitively.
|
||||
*/
|
||||
export function GetUserCaseInsensitive(
|
||||
username: string
|
||||
): Promise<FindOneResult<PublicUserDocument>> {
|
||||
return db.users.findOne({
|
||||
usernameLowercase: username.toLowerCase(),
|
||||
}) as Promise<FindOneResult<PublicUserDocument>>;
|
||||
}
|
||||
|
||||
export async function CheckIfEmailInUse(email: string) {
|
||||
const doc = await db["user-private-information"].findOne({ email });
|
||||
|
||||
return !!doc;
|
||||
}
|
||||
|
||||
export function GetUserPrivateInfo(userID: integer) {
|
||||
return db["user-private-information"].findOne({ userID });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user from their userID.
|
||||
*/
|
||||
export function GetUserWithID(userID: integer): Promise<FindOneResult<PublicUserDocument>> {
|
||||
return db.users.findOne({
|
||||
id: userID,
|
||||
}) as Promise<FindOneResult<PublicUserDocument>>;
|
||||
}
|
||||
|
||||
export function GetSettingsForUser(userID: integer) {
|
||||
return db["user-settings"].findOne({
|
||||
userID: userID,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the users for these user IDs.
|
||||
*/
|
||||
export function GetUsersWithIDs(userIDs: integer[]) {
|
||||
return db.users.find({
|
||||
id: { $in: userIDs },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user document that is expected to exist.
|
||||
* If the user document is not found, a severe error is logged, and this
|
||||
* function throws.
|
||||
*/
|
||||
export async function GetUserWithIDGuaranteed(userID: integer): Promise<PublicUserDocument> {
|
||||
const userDoc = await GetUserWithID(userID);
|
||||
|
||||
if (!userDoc) {
|
||||
logger.severe(
|
||||
`User ${userID} does not have an associated user document, but one was expected.`
|
||||
);
|
||||
throw new Error(
|
||||
`User ${userID} does not have an associated user document, but one was expected.`
|
||||
);
|
||||
}
|
||||
|
||||
return userDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function ResolveUser(usernameOrID: string) {
|
||||
// user ID passed
|
||||
if (usernameOrID.match(/^[0-9]+$/u)) {
|
||||
const intID = Number(usernameOrID);
|
||||
|
||||
return GetUserWithID(intID);
|
||||
}
|
||||
|
||||
return GetUserCaseInsensitive(usernameOrID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a formatted string indicating the user. This is used for logging.
|
||||
*/
|
||||
export function FormatUserDoc(userdoc: PublicUserDocument) {
|
||||
return `${userdoc.username} (#${userdoc.id})`;
|
||||
}
|
||||
|
||||
export async function GetUsersRanking(stats: UserGameStats) {
|
||||
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
|
||||
|
||||
const aggRes = await db["game-stats"].aggregate([
|
||||
{
|
||||
$match: {
|
||||
game: stats.game,
|
||||
playtype: stats.playtype,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
ranking: {
|
||||
$sum: {
|
||||
$cond: {
|
||||
if: {
|
||||
$gt: [
|
||||
`$ratings.${gptConfig.defaultProfileRatingAlg}`,
|
||||
stats.ratings[gptConfig.defaultProfileRatingAlg],
|
||||
],
|
||||
},
|
||||
then: 1,
|
||||
else: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return (aggRes[0].ranking + 1) as integer;
|
||||
}
|
||||
|
||||
export function GetUGPTPlaycount(userID: integer, game: Game, playtype: Playtypes[Game]) {
|
||||
return db.scores.count({ userID, game, playtype });
|
||||
}
|
||||
|
||||
export async function GetAllRankings(stats: UserGameStats) {
|
||||
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
|
||||
|
||||
const entries = await Promise.all(
|
||||
gptConfig.profileRatingAlgs.map((k) =>
|
||||
GetUsersRankingAndOutOf(stats, k).then((r) => [k, r])
|
||||
)
|
||||
);
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export async function GetUsersRankingAndOutOf(
|
||||
stats: UserGameStats,
|
||||
alg?: UGSRatingsLookup[IDStrings]
|
||||
) {
|
||||
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
|
||||
|
||||
const ratingAlg = alg ?? gptConfig.defaultProfileRatingAlg;
|
||||
|
||||
const aggRes = await db["game-stats"].aggregate([
|
||||
{
|
||||
$match: {
|
||||
game: stats.game,
|
||||
playtype: stats.playtype,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
outOf: { $sum: 1 },
|
||||
ranking: {
|
||||
$sum: {
|
||||
$cond: {
|
||||
if: {
|
||||
$gt: [`$ratings.${ratingAlg}`, stats.ratings[ratingAlg]],
|
||||
},
|
||||
then: 1,
|
||||
else: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
ranking: (aggRes[0].ranking + 1) as integer,
|
||||
outOf: aggRes[0].outOf as integer,
|
||||
};
|
||||
}
|
||||
|
||||
const FIVE_MINUTES = 1000 * 60 * 5;
|
||||
|
||||
/**
|
||||
* Returns the cutoff point for "being online" in tachi. This means the user
|
||||
* has made any page request in the past 5 minutes.
|
||||
*/
|
||||
export function GetOnlineCutoff() {
|
||||
return Date.now() - FIVE_MINUTES;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user