mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-11 00:38:28 +03:00
checkpoint: 14
This commit is contained in:
@@ -24,7 +24,7 @@ import {
|
||||
TableDocument,
|
||||
ProfileRatingLookup,
|
||||
UserGameStats,
|
||||
UserGameStatsSnapshot,
|
||||
UserGameStatsSnapshotDocument,
|
||||
QuestDocument,
|
||||
QuestSubscriptionDocument,
|
||||
QuestlineDocument,
|
||||
@@ -76,7 +76,7 @@ export type UGPTPreferenceStatsReturn =
|
||||
related: { folder: FolderDocument };
|
||||
};
|
||||
|
||||
export type UGPTHistory = Omit<UserGameStatsSnapshot, "userID" | "game" | "playtype">[];
|
||||
export type UGPTHistory = Omit<UserGameStatsSnapshotDocument, "userID" | "game" | "playtype">[];
|
||||
|
||||
export interface SessionReturns<I extends GPTString = GPTString> {
|
||||
session: SessionDocument;
|
||||
|
||||
@@ -866,6 +866,9 @@ function PR_METRIC(metric: ConfScoreMetric): ValidSchemaValue {
|
||||
|
||||
case "ENUM":
|
||||
return p.isIn(metric.values);
|
||||
|
||||
case "NULLABLE_GRAPH":
|
||||
return ["?number"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import type {
|
||||
ChartDocumentData,
|
||||
Versions,
|
||||
Classes,
|
||||
DerivedMetrics,
|
||||
Difficulties,
|
||||
ConfOptionalMetrics as ConfOptionalMetrics,
|
||||
GPTString,
|
||||
GPTStringToGame,
|
||||
GPTStringToPlaytype,
|
||||
Game,
|
||||
Judgements,
|
||||
OptionalMetrics,
|
||||
Playtype,
|
||||
Playtypes,
|
||||
Preferences,
|
||||
ProfileRatingAlgorithms,
|
||||
ProvidedMetrics,
|
||||
ScoreMeta,
|
||||
ScoreRatingAlgorithms,
|
||||
SessionRatingAlgorithms,
|
||||
SongDocumentData,
|
||||
UserAuthLevels,
|
||||
Versions,
|
||||
integer,
|
||||
DerivedMetrics,
|
||||
ProvidedMetrics,
|
||||
} from "../types";
|
||||
import type { APIPermissions } from "./api";
|
||||
import type { ImportTypes } from "./import-types";
|
||||
@@ -417,7 +417,7 @@ export type QuestSubscriptionDocument = {
|
||||
export type ScoreData<GPT extends GPTString = GPTString> = DerivedMetrics[GPT] &
|
||||
ProvidedMetrics[GPT] & {
|
||||
judgements: Partial<Record<Judgements[GPT], integer | null>>;
|
||||
optional: Partial<ConfOptionalMetrics[GPT]>;
|
||||
optional: Partial<OptionalMetrics[GPT]>;
|
||||
};
|
||||
|
||||
export interface ScoreDocument<GPT extends GPTString = GPTString> {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
Playtypes,
|
||||
SongDocument,
|
||||
} from "../types";
|
||||
import type { PrudenceSchema, ValidSchemaValue } from "prudence";
|
||||
import type { PrudenceError, ValidSchemaValue } from "prudence";
|
||||
import type { AnyZodObject } from "zod";
|
||||
|
||||
export function FormatInt(v: number): string {
|
||||
@@ -43,7 +43,7 @@ export function FormatDifficulty(chart: ChartDocument, game: Game): string {
|
||||
// it's technically correct, but in the worst way, since this isn't
|
||||
// actually possible.
|
||||
// todo: come up with something better.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
const shortDiff = gptConfig.difficulties.difficultyShorthand[ch.difficulty];
|
||||
|
||||
// gitadora should always use short diffs. they just look better.
|
||||
@@ -216,3 +216,17 @@ export function PrudenceZodShim(zodSchema: AnyZodObject): ValidSchemaValue {
|
||||
return res.error.message;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a PrudenceError into something a little more readable.
|
||||
* @param err - The prudence error to format.
|
||||
* @param foreword - A description of what kind of error this was. Defaults to "Error".
|
||||
*/
|
||||
export function FormatPrError(err: PrudenceError, foreword = "Error"): string {
|
||||
const receivedText =
|
||||
typeof err.userVal === "object" && err.userVal !== null
|
||||
? ""
|
||||
: ` | Received ${err.userVal} [${err.userVal === null ? "null" : typeof err.userVal}]`;
|
||||
|
||||
return `${foreword}: ${err.keychain} | ${err.message}${receivedText}.`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { readdirSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { ChartDocument, Difficulties, integer, Playtypes, SongDocument } from "tachi-common";
|
||||
import {
|
||||
ChartDocument,
|
||||
Difficulties,
|
||||
GPTStrings,
|
||||
integer,
|
||||
Playtypes,
|
||||
SongDocument,
|
||||
} from "tachi-common";
|
||||
import { CreateChartID, GetFreshScoreIDGenerator, MutateCollection } from "../../util";
|
||||
|
||||
interface AixChart {
|
||||
@@ -22,7 +29,7 @@ interface AixData {
|
||||
}
|
||||
|
||||
function ConvertAixStuff(d: AixData, songID: integer) {
|
||||
const searchTerms = [];
|
||||
const searchTerms: Array<string> = [];
|
||||
|
||||
if (d.title !== d.title_ascii) {
|
||||
searchTerms.push(d.title_ascii);
|
||||
@@ -40,7 +47,7 @@ function ConvertAixStuff(d: AixData, songID: integer) {
|
||||
id: songID,
|
||||
};
|
||||
|
||||
const charts = [];
|
||||
const charts: Array<ChartDocument<GPTStrings["iidx"]>> = [];
|
||||
for (const [diff, c] of Object.entries(d.charts)) {
|
||||
// wonderful ts oddity
|
||||
charts.push(ParseAixChart(d, c, diff as ChartStrings, songID));
|
||||
@@ -76,27 +83,57 @@ function SplitAixDiff(diff: ChartStrings): {
|
||||
function ParseAixChart(d: AixData, c: AixChart, diff: ChartStrings, songID: integer) {
|
||||
const { difficulty, playtype } = SplitAixDiff(diff);
|
||||
|
||||
const chart: ChartDocument<"iidx:SP" | "iidx:DP"> = {
|
||||
chartID: CreateChartID(),
|
||||
data: {
|
||||
notecount: c.note_count,
|
||||
inGameID: d.entry_id,
|
||||
"2dxtraSet": null,
|
||||
bpiCoefficient: null,
|
||||
hashSHA256: null,
|
||||
kaidenAverage: null,
|
||||
worldRecord: null,
|
||||
},
|
||||
difficulty,
|
||||
playtype,
|
||||
isPrimary: true,
|
||||
level: c.rating.toString(),
|
||||
levelNum: c.rating,
|
||||
rgcID: null,
|
||||
songID,
|
||||
tierlistInfo: {},
|
||||
versions: ["inf"],
|
||||
};
|
||||
let chart: ChartDocument<GPTStrings["iidx"]>;
|
||||
|
||||
if (playtype === "SP") {
|
||||
const temp: ChartDocument<"iidx:SP"> = {
|
||||
chartID: CreateChartID(),
|
||||
data: {
|
||||
notecount: c.note_count,
|
||||
inGameID: d.entry_id,
|
||||
"2dxtraSet": null,
|
||||
bpiCoefficient: null,
|
||||
hashSHA256: null,
|
||||
kaidenAverage: null,
|
||||
worldRecord: null,
|
||||
exhcTier: null,
|
||||
hcTier: null,
|
||||
ncTier: null,
|
||||
},
|
||||
difficulty,
|
||||
playtype,
|
||||
isPrimary: true,
|
||||
level: c.rating.toString(),
|
||||
levelNum: c.rating,
|
||||
songID,
|
||||
versions: ["INFINITAS"],
|
||||
};
|
||||
|
||||
chart = temp;
|
||||
} else {
|
||||
const temp: ChartDocument<"iidx:DP"> = {
|
||||
chartID: CreateChartID(),
|
||||
data: {
|
||||
notecount: c.note_count,
|
||||
inGameID: d.entry_id,
|
||||
"2dxtraSet": null,
|
||||
bpiCoefficient: null,
|
||||
hashSHA256: null,
|
||||
kaidenAverage: null,
|
||||
worldRecord: null,
|
||||
dpTier: null,
|
||||
},
|
||||
difficulty,
|
||||
playtype,
|
||||
isPrimary: true,
|
||||
level: c.rating.toString(),
|
||||
levelNum: c.rating,
|
||||
songID,
|
||||
versions: ["INFINITAS"],
|
||||
};
|
||||
|
||||
chart = temp;
|
||||
}
|
||||
|
||||
return chart;
|
||||
}
|
||||
@@ -106,8 +143,8 @@ if (require.main === module) {
|
||||
|
||||
const getSongID = GetFreshScoreIDGenerator("iidx");
|
||||
|
||||
const newSongs = [];
|
||||
const newCharts = [];
|
||||
const newSongs: Array<SongDocument<"iidx">> = [];
|
||||
const newCharts: Array<ChartDocument<GPTStrings["iidx"]>> = [];
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
const songID = getSongID();
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ChartDocument,
|
||||
Difficulties,
|
||||
GetGamePTConfig,
|
||||
GPTSupportedVersions,
|
||||
Versions,
|
||||
integer,
|
||||
SongDocument,
|
||||
} from "tachi-common";
|
||||
@@ -38,7 +38,7 @@ program
|
||||
|
||||
program.parse(process.argv);
|
||||
const options = program.opts() as {
|
||||
version: GPTSupportedVersions["iidx:SP"];
|
||||
version: Versions["iidx:SP"];
|
||||
index: "0" | "1";
|
||||
basedir: string;
|
||||
omni: boolean;
|
||||
|
||||
Vendored
+2
-2
@@ -42,7 +42,7 @@ import type {
|
||||
TachiAPIClientDocument,
|
||||
UGPTSettings,
|
||||
UserGameStats,
|
||||
UserGameStatsSnapshot,
|
||||
UserGameStatsSnapshotDocument,
|
||||
UserSettings,
|
||||
KsHookSettingsDocument,
|
||||
ImportTrackerDocument as ImportTrackerDocument,
|
||||
@@ -161,7 +161,7 @@ const db = {
|
||||
tables: monkDB.get<TableDocument>("tables"),
|
||||
"invite-locks": monkDB.get<{ userID: integer; locked: boolean }>("invite-locks"),
|
||||
"game-settings": monkDB.get<UGPTSettings>("game-settings"),
|
||||
"game-stats-snapshots": monkDB.get<UserGameStatsSnapshot>("game-stats-snapshots"),
|
||||
"game-stats-snapshots": monkDB.get<UserGameStatsSnapshotDocument>("game-stats-snapshots"),
|
||||
"user-settings": monkDB.get<UserSettings>("user-settings"),
|
||||
"user-private-information": monkDB.get<PrivateUserInfoDocument>("user-private-information"),
|
||||
"api-clients": monkDB.get<TachiAPIClientDocument>("api-clients"),
|
||||
|
||||
@@ -2,7 +2,7 @@ import db from "external/mongo/db";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { GetMillisecondsSince } from "utils/misc";
|
||||
import { GetAllRankings } from "utils/user";
|
||||
import type { UserGameStats, UserGameStatsSnapshot } from "tachi-common";
|
||||
import type { UserGameStats, UserGameStatsSnapshotDocument } from "tachi-common";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
@@ -10,7 +10,7 @@ const logger = CreateLogCtx(__filename);
|
||||
// nonsense happens. we'll have to see.
|
||||
const currentTime = new Date().setUTCHours(0, 0, 0, 0);
|
||||
|
||||
let batchWrite: Array<UserGameStatsSnapshot> = [];
|
||||
let batchWrite: Array<UserGameStatsSnapshotDocument> = [];
|
||||
|
||||
// This code is intentionally *very* robust, and handles a lot of unanticipated failures
|
||||
// because if it breaks, we brick the database.
|
||||
@@ -48,7 +48,7 @@ export async function UGSSnapshot() {
|
||||
GetAllRankings(ugs),
|
||||
]);
|
||||
|
||||
const ugsSnapshot: UserGameStatsSnapshot = {
|
||||
const ugsSnapshot: UserGameStatsSnapshotDocument = {
|
||||
...ugs,
|
||||
playcount,
|
||||
rankings,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { VERSION_INFO } from "lib/constants/version";
|
||||
import { PullDatabaseSeeds } from "lib/database-seeds/repo";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { RecalcAllScores } from "utils/calculations/recalc-scores";
|
||||
@@ -7,7 +6,7 @@ import fetch from "utils/fetch";
|
||||
import type {
|
||||
ChartDocument,
|
||||
Difficulties,
|
||||
GPTSupportedVersions,
|
||||
Versions,
|
||||
integer,
|
||||
Playtypes,
|
||||
SongDocument,
|
||||
@@ -82,7 +81,7 @@ export async function UpdatePoyashiData() {
|
||||
songID: integer,
|
||||
playtype: Playtypes["iidx"],
|
||||
diff: Difficulties["iidx:DP" | "iidx:SP"],
|
||||
version: GPTSupportedVersions["iidx:DP" | "iidx:SP"]
|
||||
version: Versions["iidx:DP" | "iidx:SP"]
|
||||
) {
|
||||
for (const chart of iidxCharts) {
|
||||
if (
|
||||
@@ -119,7 +118,7 @@ export async function UpdatePoyashiData() {
|
||||
}
|
||||
|
||||
// current poyashi version is 29
|
||||
const tachiChart = FindChartWithPTDFVersion(tachiSong.id, playtype, diff, "29");
|
||||
const tachiChart = FindChartWithPTDFVersion(tachiSong.id, playtype, diff, "CastHour");
|
||||
|
||||
if (!tachiChart) {
|
||||
logger.warn(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import db from "external/mongo/db";
|
||||
import { decode } from "html-entities";
|
||||
import { VERSION_INFO } from "lib/constants/version";
|
||||
import { BacksyncCollectionToBothBranches, PullDatabaseSeeds } from "lib/database-seeds/repo";
|
||||
import { BacksyncCollectionToBothBranches } from "lib/database-seeds/repo";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { parse } from "node-html-parser";
|
||||
import { RecalcAllScores } from "utils/calculations/recalc-scores";
|
||||
@@ -60,7 +59,7 @@ export async function UpdateDPTiers() {
|
||||
|
||||
const result = await db.charts.iidx.update(
|
||||
{
|
||||
versions: "29",
|
||||
versions: "CastHour",
|
||||
playtype: "DP",
|
||||
songID: song.id,
|
||||
difficulty,
|
||||
|
||||
@@ -61,7 +61,7 @@ async function FetchSP12Data() {
|
||||
const updatedChartIDs: Array<string> = [];
|
||||
|
||||
for (const sh of rj.sheets) {
|
||||
let chart: ChartDocument | null;
|
||||
let chart: ChartDocument<"iidx:SP"> | null;
|
||||
|
||||
try {
|
||||
chart = await HumanisedTitleLookup(sh.title);
|
||||
@@ -142,26 +142,26 @@ async function FetchSP12Data() {
|
||||
|
||||
val = parseFloat(val.toFixed(2));
|
||||
|
||||
let ktKey: keyof ChartDocument<"iidx:SP">["tierlistInfo"];
|
||||
let ktKey: "exhcTier" | "hcTier" | "ncTier";
|
||||
|
||||
if (key === "exh") {
|
||||
ktKey = "kt-EXHC";
|
||||
ktKey = "exhcTier";
|
||||
} else if (key === "hard") {
|
||||
ktKey = "kt-HC";
|
||||
ktKey = "hcTier";
|
||||
} else {
|
||||
ktKey = "kt-NC";
|
||||
ktKey = "ncTier";
|
||||
}
|
||||
|
||||
// If EXH, just save the string version as 12.xx
|
||||
// Else, use the A/S+ thing that people are used to.
|
||||
const text =
|
||||
ktKey === "kt-EXHC"
|
||||
ktKey === "exhcTier"
|
||||
? val.toFixed(2)
|
||||
: `12${stringVal.replace(/(個人差|地力)/u, "")}`;
|
||||
|
||||
const idvDiff = stringVal.includes("個人差");
|
||||
|
||||
const existingTlInfo = chart.tierlistInfo[ktKey];
|
||||
const existingTlInfo = chart.data[ktKey];
|
||||
|
||||
if (
|
||||
existingTlInfo &&
|
||||
@@ -180,7 +180,7 @@ async function FetchSP12Data() {
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
[`tierlistInfo.${ktKey}`]: {
|
||||
[`data.${ktKey}`]: {
|
||||
text,
|
||||
value: val,
|
||||
individualDifference: idvDiff,
|
||||
@@ -254,7 +254,7 @@ async function HumanisedTitleLookup(originalTitle: string) {
|
||||
);
|
||||
}
|
||||
|
||||
return chart;
|
||||
return chart as ChartDocument<"iidx:SP">;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
|
||||
@@ -116,12 +116,12 @@ export async function DeorphanIfInQueue<I extends GPTString>(
|
||||
game: IDStringToGame[I],
|
||||
orphanMatchCriteria: FilterQuery<OrphanChart<I>>
|
||||
): Promise<ChartDocument<I> | null> {
|
||||
const orphanChart = (await db["orphan-chart-queue"].findOne(
|
||||
const orphanChart = await db["orphan-chart-queue"].findOne(
|
||||
{ idString, ...orphanMatchCriteria },
|
||||
{
|
||||
projectID: true,
|
||||
}
|
||||
)) as WithID<OrphanChart<I>> | null;
|
||||
);
|
||||
|
||||
if (!orphanChart) {
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import db from "external/mongo/db";
|
||||
import type { KtLogger } from "lib/logger/logger";
|
||||
import type {
|
||||
Game,
|
||||
GPTSupportedVersions,
|
||||
Versions,
|
||||
GPTString,
|
||||
integer,
|
||||
PBScoreDocument,
|
||||
@@ -456,7 +456,7 @@ async function GetBestJubilityOnSongs(
|
||||
return r.map((e) => e.doc);
|
||||
}
|
||||
|
||||
const CURRENT_JUBEAT_HOT_VERSION: GPTSupportedVersions["jubeat:Single"] = "festo";
|
||||
const CURRENT_JUBEAT_HOT_VERSION: Versions["jubeat:Single"] = "festo";
|
||||
|
||||
export async function GetPBsForJubility(userID: integer) {
|
||||
const hotSongs = await db.songs.jubeat.find(
|
||||
|
||||
@@ -14,7 +14,7 @@ import { FindSongOnID } from "utils/queries/songs";
|
||||
import type { ConverterFunction } from "../../types";
|
||||
import type { CGContext, CGMusecaScore } from "../types";
|
||||
import type { DryScore } from "lib/score-import/framework/common/types";
|
||||
import type { Difficulties, GPTSupportedVersions } from "tachi-common";
|
||||
import type { Difficulties, Versions } from "tachi-common";
|
||||
|
||||
export const ConverterAPICGMuseca: ConverterFunction<CGMusecaScore, CGContext> = async (
|
||||
data,
|
||||
@@ -94,7 +94,7 @@ function ConvertDifficulty(diff: number): Difficulties["museca:Single"] {
|
||||
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
|
||||
}
|
||||
|
||||
function ConvertVersion(ver: number): GPTSupportedVersions["museca:Single"] {
|
||||
function ConvertVersion(ver: number): Versions["museca:Single"] {
|
||||
switch (ver) {
|
||||
case 1:
|
||||
return "1.5-b";
|
||||
|
||||
@@ -13,13 +13,7 @@ import { FindSongOnID } from "utils/queries/songs";
|
||||
import type { ConverterFunction } from "../../types";
|
||||
import type { CGContext, CGPopnScore } from "../types";
|
||||
import type { DryScore } from "lib/score-import/framework/common/types";
|
||||
import type {
|
||||
Difficulties,
|
||||
GPTSupportedVersions,
|
||||
HitMetaLookup,
|
||||
Lamps,
|
||||
integer,
|
||||
} from "tachi-common";
|
||||
import type { Difficulties, Versions, HitMetaLookup, Lamps, integer } from "tachi-common";
|
||||
|
||||
export const ConverterAPICGPopn: ConverterFunction<CGPopnScore, CGContext> = async (
|
||||
data,
|
||||
@@ -112,7 +106,7 @@ function ConvertDifficulty(diff: number): Difficulties["popn:9B"] {
|
||||
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
|
||||
}
|
||||
|
||||
function ConvertVersion(ver: number): GPTSupportedVersions["popn:9B"] {
|
||||
function ConvertVersion(ver: number): Versions["popn:9B"] {
|
||||
switch (ver) {
|
||||
case 26:
|
||||
return "kaimei";
|
||||
|
||||
@@ -13,7 +13,7 @@ import { FindSongOnID } from "utils/queries/songs";
|
||||
import type { ConverterFunction } from "../../types";
|
||||
import type { CGContext, CGSDVXScore } from "../types";
|
||||
import type { DryScore } from "lib/score-import/framework/common/types";
|
||||
import type { GPTSupportedVersions, Lamps } from "tachi-common";
|
||||
import type { Versions, Lamps } from "tachi-common";
|
||||
|
||||
export const ConverterAPICGSDVX: ConverterFunction<CGSDVXScore, CGContext> = async (
|
||||
data,
|
||||
@@ -91,7 +91,7 @@ function ConvertDifficulty(diff: number) {
|
||||
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
|
||||
}
|
||||
|
||||
function ConvertVersion(ver: number): GPTSupportedVersions["sdvx:Single"] {
|
||||
function ConvertVersion(ver: number): Versions["sdvx:Single"] {
|
||||
switch (ver) {
|
||||
case 1:
|
||||
return "booth";
|
||||
|
||||
@@ -29,7 +29,9 @@ const PR_KAI_IIDX_SCORE = {
|
||||
timestamp: "string",
|
||||
};
|
||||
|
||||
function ResolveKaiLamp(lamp: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7): GetEnumValue<"iidx:DP" | "iidx:SP", "lamp"> {
|
||||
function ResolveKaiLamp(
|
||||
lamp: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
): GetEnumValue<"iidx:DP" | "iidx:SP", "lamp"> {
|
||||
switch (lamp) {
|
||||
case 0:
|
||||
return "NO PLAY";
|
||||
|
||||
@@ -28,7 +28,7 @@ import type {
|
||||
BatchManualScore,
|
||||
ChartDocument,
|
||||
Difficulties,
|
||||
GPTSupportedVersions,
|
||||
Versions,
|
||||
Grades,
|
||||
GPTString,
|
||||
ImportTypes,
|
||||
@@ -315,7 +315,7 @@ export async function ResolveMatchTypeToTachiData(
|
||||
chart = await FindSDVXChartOnInGameIDVersion(
|
||||
identifier,
|
||||
diff,
|
||||
context.version as GPTSupportedVersions["sdvx:Single"]
|
||||
context.version as Versions["sdvx:Single"]
|
||||
);
|
||||
} else {
|
||||
chart = await FindSDVXChartOnInGameID(identifier, diff);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Game, GPTSupportedVersions, GPTString, Playtype } from "tachi-common";
|
||||
import type { Game, Versions, GPTString, Playtype } from "tachi-common";
|
||||
|
||||
export interface BatchManualContext {
|
||||
game: Game;
|
||||
playtype: Playtype;
|
||||
service: string;
|
||||
version: GPTSupportedVersions[GPTString] | null;
|
||||
version: Versions[GPTString] | null;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CSVParseError, NaiveCSVParse } from "utils/naive-csv-parser";
|
||||
import type { ParserFunctionReturns } from "../types";
|
||||
import type { EamusementScoreData, IIDXEamusementCSVContext, IIDXEamusementCSVData } from "./types";
|
||||
import type { KtLogger } from "lib/logger/logger";
|
||||
import type { integer, GPTSupportedVersions, Playtypes } from "tachi-common";
|
||||
import type { integer, Versions, Playtypes } from "tachi-common";
|
||||
|
||||
export enum EAM_VERSION_NAMES {
|
||||
"1st&substream" = 1,
|
||||
@@ -178,7 +178,7 @@ export function IIDXCSVParse(csvBuffer: Buffer, playtype: Playtypes["iidx"], log
|
||||
|
||||
return {
|
||||
iterableData,
|
||||
version: gameVersion.toString() as GPTSupportedVersions["iidx:DP" | "iidx:SP"],
|
||||
version: gameVersion.toString() as Versions["iidx:DP" | "iidx:SP"],
|
||||
hasBeginnerAndLegg,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GPTSupportedVersions } from "tachi-common";
|
||||
import type { Versions } from "tachi-common";
|
||||
|
||||
export interface EamusementScoreData {
|
||||
difficulty: "ANOTHER" | "BEGINNER" | "HYPER" | "LEGGENDARIA" | "NORMAL";
|
||||
@@ -30,7 +30,7 @@ export type RawIIDXEamusementCSVData = BaseProps &
|
||||
|
||||
export interface IIDXEamusementCSVContext {
|
||||
playtype: "DP" | "SP";
|
||||
importVersion: GPTSupportedVersions["iidx:DP" | "iidx:SP"];
|
||||
importVersion: Versions["iidx:DP" | "iidx:SP"];
|
||||
hasBeginnerAndLegg: boolean;
|
||||
service: string;
|
||||
}
|
||||
|
||||
@@ -3,16 +3,13 @@ import {
|
||||
SongOrChartNotFoundFailure,
|
||||
SkipScoreFailure,
|
||||
} from "../../../framework/common/converter-failures";
|
||||
import {
|
||||
GenericGetGradeAndPercent,
|
||||
ParseDateFromString,
|
||||
} from "../../../framework/common/score-utils";
|
||||
import { ParseDateFromString } from "../../../framework/common/score-utils";
|
||||
import { FindChartWithPTDFVersion } from "utils/queries/charts";
|
||||
import { FindSongOnTitleInsensitive } from "utils/queries/songs";
|
||||
import type { DryScore } from "../../../framework/common/types";
|
||||
import type { ConverterFunction } from "../../common/types";
|
||||
import type { S3Score } from "./types";
|
||||
import type { Difficulties, GPTSupportedVersions, Lamps, Playtypes } from "tachi-common";
|
||||
import type { Difficulties, Playtypes, Versions } from "tachi-common";
|
||||
import type { EmptyObject } from "utils/types";
|
||||
|
||||
export function ParseDifficulty(diff: S3Score["diff"]): {
|
||||
@@ -71,23 +68,23 @@ export function ResolveS3Lamp(data: S3Score): GetEnumValue<"iidx:DP" | "iidx:SP"
|
||||
}
|
||||
}
|
||||
|
||||
const S3_VERSION_CONV: Record<string, GPTSupportedVersions["iidx:SP"]> = {
|
||||
"3rd": "3-cs",
|
||||
"4th": "4-cs",
|
||||
"5th": "5-cs",
|
||||
"6th": "6-cs",
|
||||
"7th": "7-cs",
|
||||
"8th": "8-cs",
|
||||
"9th": "9-cs",
|
||||
"10th": "10-cs",
|
||||
red: "11-cs",
|
||||
hs: "12-cs",
|
||||
dd: "13-cs",
|
||||
gold: "14-cs",
|
||||
djt: "15-cs",
|
||||
emp: "16-cs",
|
||||
pb: "16-cs",
|
||||
us: "bmus",
|
||||
const S3_VERSION_CONV: Record<string, Versions["iidx:SP"]> = {
|
||||
"3rd": "3rd Style CS",
|
||||
"4th": "4th Style CS",
|
||||
"5th": "5th Style CS",
|
||||
"6th": "6th Style CS",
|
||||
"7th": "7th Style CS",
|
||||
"8th": "8th Style CS",
|
||||
"9th": "9th Style CS",
|
||||
"10th": "10th Style CS",
|
||||
red: "IIDX RED CS",
|
||||
hs: "HAPPY SKY CS",
|
||||
dd: "DISTORTED CS",
|
||||
gold: "GOLD CS",
|
||||
djt: "DJ TROOPERS CS",
|
||||
emp: "EMPRESS CS",
|
||||
pb: "EMPRESS CS",
|
||||
us: "BEATMANIA US",
|
||||
};
|
||||
|
||||
function ConvertVersion(joinedStyles: string) {
|
||||
@@ -139,8 +136,6 @@ export const ConvertFileS3: ConverterFunction<S3Score, EmptyObject> = async (
|
||||
);
|
||||
}
|
||||
|
||||
const { percent, grade } = GenericGetGradeAndPercent("iidx", data.exscore, chart);
|
||||
|
||||
const lamp = ResolveS3Lamp(data);
|
||||
|
||||
const timeAchieved = ParseDateFromString(data.date);
|
||||
@@ -163,8 +158,6 @@ export const ConvertFileS3: ConverterFunction<S3Score, EmptyObject> = async (
|
||||
importType: "file/solid-state-squad",
|
||||
service: "Solid State Squad",
|
||||
scoreData: {
|
||||
percent,
|
||||
grade,
|
||||
score: data.exscore,
|
||||
lamp,
|
||||
judgements,
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import {
|
||||
InternalFailure,
|
||||
InvalidScoreFailure,
|
||||
SongOrChartNotFoundFailure,
|
||||
} from "lib/score-import/framework/common/converter-failures";
|
||||
import { GenericGetGradeAndPercent } from "lib/score-import/framework/common/score-utils";
|
||||
import { FindChartWithPTDF } from "utils/queries/charts";
|
||||
import { FindSongOnTitle } from "utils/queries/songs";
|
||||
import type { ConverterFunction } from "../../common/types";
|
||||
import type { MyPageRecordsParsedPB } from "./types";
|
||||
import type { DryScore } from "lib/score-import/framework/common/types";
|
||||
import type { Difficulties, Lamps } from "tachi-common";
|
||||
import type { Difficulties } from "tachi-common";
|
||||
import type { GetEnumValue } from "tachi-common/types/metrics";
|
||||
import type { EmptyObject } from "utils/types";
|
||||
|
||||
const DIFFICULTIES: Array<Difficulties["wacca:Single"]> = ["NORMAL", "HARD", "EXPERT", "INFERNO"];
|
||||
@@ -41,7 +40,9 @@ const ConvertMyPageScraperRecordsCSV: ConverterFunction<
|
||||
const difficulty = DIFFICULTIES[data.diffIndex];
|
||||
|
||||
if (difficulty === undefined) {
|
||||
throw new InternalFailure(`We somehow got an invalid difficulty index ${data.diffIndex}.`);
|
||||
throw new InvalidScoreFailure(
|
||||
`Invalid difficulty index of ${data.diffIndex}. This corresponds to some unknown difficulty. Invalid input?`
|
||||
);
|
||||
}
|
||||
|
||||
const humanisedChartTitle = `${song.title} [${difficulty}]`;
|
||||
@@ -78,18 +79,16 @@ const ConvertMyPageScraperRecordsCSV: ConverterFunction<
|
||||
|
||||
if (data.score === 1_000_000 && lamp !== "ALL MARVELOUS") {
|
||||
throw new InvalidScoreFailure(
|
||||
`MASTER score of ${data.score}, but lamp ${lamp} is not ALL MARVELOUS.`
|
||||
`Got a score of ${data.score}, but lamp ${lamp} is not ALL MARVELOUS.`
|
||||
);
|
||||
}
|
||||
|
||||
if (lamp === "ALL MARVELOUS" && data.score !== 1_000_000) {
|
||||
throw new InvalidScoreFailure(
|
||||
`AM lamp ${lamp}, but score is ${data.score}, not 1,000,000.`
|
||||
`Got a lamp of '${lamp}', but score is ${data.score}, not 1,000,000.`
|
||||
);
|
||||
}
|
||||
|
||||
const { percent, grade } = GenericGetGradeAndPercent("wacca", data.score, chart);
|
||||
|
||||
const dryScore: DryScore<"wacca:Single"> = {
|
||||
service: "mypage-scraper",
|
||||
game: "wacca",
|
||||
@@ -102,17 +101,11 @@ const ConvertMyPageScraperRecordsCSV: ConverterFunction<
|
||||
scoreData: {
|
||||
score: data.score,
|
||||
lamp,
|
||||
percent,
|
||||
grade,
|
||||
judgements: {},
|
||||
optional: {},
|
||||
},
|
||||
};
|
||||
|
||||
logger.verbose(
|
||||
`Returning dryscore with ${dryScore.scoreData.score} for ${humanisedChartTitle}`
|
||||
);
|
||||
|
||||
return { chart, song, dryScore };
|
||||
};
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export function ParseMyPageScraperRecordsCSV(
|
||||
try {
|
||||
rawCSVRecords = parse(fileData.buffer, { columns: true }) as Array<unknown>;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line cadence/no-instanceof
|
||||
if (err instanceof CSVError) {
|
||||
throw new ScoreImportFatalError(400, `Failed to parse CSV: ${err.message}`);
|
||||
}
|
||||
@@ -124,6 +125,7 @@ export function ParseMyPageScraperPlayerCSV(
|
||||
try {
|
||||
csvRecords = parse(fileData.buffer, { columns: true }) as Array<Record<string, string>>;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line cadence/no-instanceof
|
||||
if (err instanceof CSVError) {
|
||||
throw new ScoreImportFatalError(400, `Failed to parse CSV: ${err.message}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GPTSupportedVersions, integer } from "tachi-common";
|
||||
import type { Versions, integer } from "tachi-common";
|
||||
|
||||
export interface BarbatosScore {
|
||||
difficulty: 0 | 1 | 2 | 3 | 4;
|
||||
@@ -52,5 +52,5 @@ export interface BarbatosSDVX6Score {
|
||||
|
||||
export interface BarbatosContext {
|
||||
timeReceived: number;
|
||||
version: GPTSupportedVersions["sdvx:Single"];
|
||||
version: Versions["sdvx:Single"];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GPTSupportedVersions, integer } from "tachi-common";
|
||||
import type { Versions, integer } from "tachi-common";
|
||||
|
||||
export interface FervidexStaticScore {
|
||||
clear_type: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
@@ -11,7 +11,7 @@ export interface FervidexStaticScore {
|
||||
}
|
||||
|
||||
export interface FervidexStaticContext {
|
||||
version: GPTSupportedVersions["iidx:DP" | "iidx:SP"];
|
||||
version: Versions["iidx:DP" | "iidx:SP"];
|
||||
}
|
||||
|
||||
export interface FervidexStaticHeaders {
|
||||
|
||||
@@ -11,15 +11,14 @@ import {
|
||||
REV_NORMAL,
|
||||
REV_OMNIMIX,
|
||||
} from "lib/constants/ea3id";
|
||||
import { CreateScoreID } from "lib/score-import/framework/score-importing/score-id";
|
||||
import { p } from "prudence";
|
||||
import { ParseEA3SoftID } from "utils/ea3id";
|
||||
import { FormatPrError, optNull } from "utils/prudence";
|
||||
import type { ParserFunctionReturns } from "../../common/types";
|
||||
import type { FerHeaders as FervidexHeaders, FervidexContext, FervidexScore } from "./types";
|
||||
import type { FervidexContext, FerHeaders as FervidexHeaders, FervidexScore } from "./types";
|
||||
import type { KtLogger } from "lib/logger/logger";
|
||||
import type { PrudenceSchema, ValidSchemaValue } from "prudence";
|
||||
import type { GPTSupportedVersions, integer } from "tachi-common";
|
||||
import type { Versions, integer } from "tachi-common";
|
||||
|
||||
const PR_FERVIDEX: PrudenceSchema = {
|
||||
chart: p.isIn("spb", "spn", "dpn", "sph", "dph", "spa", "dpa", "spl", "dpl"),
|
||||
@@ -82,7 +81,7 @@ const PR_FERVIDEX: PrudenceSchema = {
|
||||
export function SoftwareIDToVersion(
|
||||
model: string,
|
||||
logger: KtLogger
|
||||
): GPTSupportedVersions["iidx:DP" | "iidx:SP"] {
|
||||
): Versions["iidx:DP" | "iidx:SP"] {
|
||||
try {
|
||||
const data = ParseEA3SoftID(model);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GPTSupportedVersions, integer } from "tachi-common";
|
||||
import type { Versions, integer } from "tachi-common";
|
||||
|
||||
export interface FervidexScore {
|
||||
chart: `${"dp" | "sp"}${"a" | "b" | "h" | "l" | "n"}`;
|
||||
@@ -65,7 +65,7 @@ export interface FervidexScore {
|
||||
}
|
||||
|
||||
export interface FervidexContext {
|
||||
version: GPTSupportedVersions["iidx:DP" | "iidx:SP"];
|
||||
version: Versions["iidx:DP" | "iidx:SP"];
|
||||
timeReceived: number;
|
||||
userID: integer;
|
||||
}
|
||||
|
||||
@@ -92,11 +92,11 @@ router.get("/", async (req, res) => {
|
||||
(e) => (e as ChartDocument<"iidx:DP" | "iidx:SP">).data["2dxtraSet"] === null
|
||||
);
|
||||
} else {
|
||||
const iidxSettings = (await db["game-settings"].findOne({
|
||||
const iidxSettings = await db["game-settings"].findOne({
|
||||
userID: req[SYMBOL_TACHI_API_AUTH].userID,
|
||||
game,
|
||||
playtype,
|
||||
})) as UGPTSettings<"iidx:DP" | "iidx:SP"> | null;
|
||||
});
|
||||
|
||||
if (!iidxSettings || !iidxSettings.preferences.gameSpecific.display2DXTra) {
|
||||
charts = charts.filter(
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
GetUsersRankingAndOutOf,
|
||||
GetUsersWithIDs,
|
||||
} from "utils/user";
|
||||
import type { integer, PBScoreDocument, UserGameStatsSnapshot } from "tachi-common";
|
||||
import type { integer, PBScoreDocument, UserGameStatsSnapshotDocument } from "tachi-common";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
@@ -155,17 +155,18 @@ router.get(
|
||||
playtype: 0,
|
||||
},
|
||||
}
|
||||
)) as Array<Omit<UserGameStatsSnapshot, "game" | "playtype" | "userID">>;
|
||||
)) as Array<Omit<UserGameStatsSnapshotDocument, "game" | "playtype" | "userID">>;
|
||||
|
||||
const currentSnapshot: Omit<UserGameStatsSnapshot, "game" | "playtype" | "userID"> = {
|
||||
classes: stats.classes,
|
||||
ratings: stats.ratings,
|
||||
const currentSnapshot: Omit<UserGameStatsSnapshotDocument, "game" | "playtype" | "userID"> =
|
||||
{
|
||||
classes: stats.classes,
|
||||
ratings: stats.ratings,
|
||||
|
||||
// lazy, should probably be this midnight
|
||||
timestamp: Date.now(),
|
||||
playcount: await GetUGPTPlaycount(user.id, game, playtype),
|
||||
rankings: await GetAllRankings(stats),
|
||||
};
|
||||
// lazy, should probably be this midnight
|
||||
timestamp: Date.now(),
|
||||
playcount: await GetUGPTPlaycount(user.id, game, playtype),
|
||||
rankings: await GetAllRankings(stats),
|
||||
};
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
|
||||
+2
-2
@@ -201,11 +201,11 @@ t.test("PATCH /api/v1/users/:userID/games/:game/:playtype/settings", (t) => {
|
||||
"Should only update the mutated properties."
|
||||
);
|
||||
|
||||
const data = (await db["game-settings"].findOne({
|
||||
const data = await db["game-settings"].findOne({
|
||||
userID: 1,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
})) as UGPTSettings<"iidx:DP" | "iidx:SP"> | null;
|
||||
});
|
||||
|
||||
t.equal(data?.preferences.gameSpecific.bpiTarget, target);
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function TachiScoreDataToBeatorajaFormat(
|
||||
sha256,
|
||||
player: username,
|
||||
playcount,
|
||||
clear: LAMP_TO_BEATORAJA[scoreData.lampIndex] ?? 0,
|
||||
clear: LAMP_TO_BEATORAJA[scoreData.lamp.index] ?? 0,
|
||||
date: pbScore.timeAchieved ?? 0,
|
||||
maxcombo: scoreData.optional.maxCombo ?? 0,
|
||||
gauge: scoreData.optional.gauge ?? 0,
|
||||
|
||||
@@ -4,12 +4,19 @@ import CreateLogCtx from "lib/logger/logger";
|
||||
import { MStoS } from "utils/misc";
|
||||
import { GetPBOnChart, GetServerRecordOnChart } from "utils/scores";
|
||||
import type { USCServerScore } from "./types";
|
||||
import type { ChartDocument, integer, PBScoreDocument, ScoreDocument } from "tachi-common";
|
||||
import type {
|
||||
ChartDocument,
|
||||
GPTStrings,
|
||||
integer,
|
||||
PBScoreDocument,
|
||||
ScoreDocument,
|
||||
} from "tachi-common";
|
||||
import type { GetEnumValue } from "tachi-common/types/metrics";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
export const TACHI_LAMP_TO_USC: Record<
|
||||
PBScoreDocument<"usc:Controller" | "usc:Keyboard">["scoreData"]["lamp"],
|
||||
GetEnumValue<GPTStrings["usc"], "lamp">,
|
||||
USCServerScore["lamp"]
|
||||
> = {
|
||||
// we don't do NO PLAY, so its not handled.
|
||||
|
||||
@@ -6,14 +6,7 @@ import { exec } from "child_process";
|
||||
import crypto from "crypto";
|
||||
import { URL } from "url";
|
||||
import type { KtLogger } from "lib/logger/logger";
|
||||
import type {
|
||||
Game,
|
||||
GamePTConfig,
|
||||
integer,
|
||||
Playtype,
|
||||
GPTSupportedVersions,
|
||||
GPTString,
|
||||
} from "tachi-common";
|
||||
import type { Game, GamePTConfig, integer, Playtype, Versions, GPTString } from "tachi-common";
|
||||
|
||||
// https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
|
||||
// the developer of this has migrated everything to Force ES6 style modules,
|
||||
@@ -329,7 +322,7 @@ export function StringIsGameVersion(
|
||||
game: Game,
|
||||
playtype: Playtype,
|
||||
version: string
|
||||
): version is GPTSupportedVersions[GPTString] {
|
||||
): version is Versions[GPTString] {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
// @ts-expect-error yes, we know!
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
ChartDocument,
|
||||
Difficulties,
|
||||
Game,
|
||||
GPTSupportedVersions,
|
||||
Versions,
|
||||
GPTString,
|
||||
integer,
|
||||
Playtype,
|
||||
@@ -41,13 +41,7 @@ export function FindChartWithPTDFVersion<
|
||||
G extends Game = Game,
|
||||
P extends Playtypes[G] = Playtypes[G],
|
||||
I extends GPTString = GPTString
|
||||
>(
|
||||
game: G,
|
||||
songID: integer,
|
||||
playtype: P,
|
||||
difficulty: Difficulties[I],
|
||||
version: GPTSupportedVersions[I]
|
||||
) {
|
||||
>(game: G, songID: integer, playtype: P, difficulty: Difficulties[I], version: Versions[I]) {
|
||||
return db.charts[game].findOne({
|
||||
songID,
|
||||
playtype,
|
||||
@@ -118,7 +112,7 @@ export function FindIIDXChartOnInGameIDVersion(
|
||||
inGameID: number,
|
||||
playtype: Playtype,
|
||||
difficulty: Difficulties[GPTString],
|
||||
version: GPTSupportedVersions[GPTString]
|
||||
version: Versions[GPTString]
|
||||
) {
|
||||
return db.charts.iidx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
@@ -137,7 +131,7 @@ export function FindChartOnInGameIDVersion<I extends GPTString = GPTString>(
|
||||
inGameID: number,
|
||||
playtype: Playtype,
|
||||
difficulty: Difficulties[I],
|
||||
version: GPTSupportedVersions[I]
|
||||
version: Versions[I]
|
||||
) {
|
||||
return db.charts[game].findOne({
|
||||
"data.inGameID": inGameID,
|
||||
@@ -181,7 +175,7 @@ export function FindSDVXChartOnInGameID(
|
||||
export function FindSDVXChartOnInGameIDVersion(
|
||||
inGameID: number,
|
||||
difficulty: Difficulties["sdvx:Single"] | "ANY_INF",
|
||||
version: GPTSupportedVersions["sdvx:Single"]
|
||||
version: Versions["sdvx:Single"]
|
||||
) {
|
||||
const diffQuery =
|
||||
difficulty === "ANY_INF"
|
||||
@@ -198,7 +192,7 @@ export function FindSDVXChartOnInGameIDVersion(
|
||||
export function FindSDVXChartOnDFVersion(
|
||||
songID: integer,
|
||||
difficulty: Difficulties["sdvx:Single"] | "ANY_INF",
|
||||
version: GPTSupportedVersions["sdvx:Single"]
|
||||
version: Versions["sdvx:Single"]
|
||||
) {
|
||||
const diffQuery =
|
||||
difficulty === "ANY_INF"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import db from "external/mongo/db";
|
||||
import { ONE_HOUR } from "lib/constants/time";
|
||||
import NodeCache from "node-cache";
|
||||
import type { Game, gameClasses, GPTString, integer, Playtype } from "tachi-common";
|
||||
import type { Classes, Game, GPTString, integer, Playtype } from "tachi-common";
|
||||
|
||||
const classDistCache = new NodeCache();
|
||||
|
||||
export async function GetClassDistribution(
|
||||
game: Game,
|
||||
playtype: Playtype,
|
||||
className: gameClasses.GameClassSets[GPTString]
|
||||
className: Classes[GPTString]
|
||||
) {
|
||||
const cacheKey = `${game}:${playtype}:${className}`;
|
||||
const cache = classDistCache.get<Record<string, integer>>(cacheKey);
|
||||
|
||||
Reference in New Issue
Block a user