mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-29 18:37:58 +03:00
fix: DDR score validation with judgements using step counts (#1164)
* fix: DDR score validation with judgements using step counts for accurate computation * feat: parse step counts from SSQ files * chore: lint fix
This commit is contained in:
@@ -59,6 +59,7 @@ export const DDR_CONF = {
|
||||
songData: z.strictObject({
|
||||
inGameID: zodNonNegativeInt,
|
||||
flareCategory: DDR_FLARE_CATEGORIES,
|
||||
basename: z.string().optional(),
|
||||
}),
|
||||
} as const satisfies INTERNAL_GAME_CONFIG;
|
||||
|
||||
@@ -185,6 +186,7 @@ export const DDR_SP_CONF = {
|
||||
|
||||
chartData: z.strictObject({
|
||||
inGameID: zodNonNegativeInt,
|
||||
stepCount: zodNonNegativeInt.optional(),
|
||||
}),
|
||||
|
||||
preferences: z.strictObject({}),
|
||||
|
||||
Generated
+8
@@ -435,6 +435,9 @@ importers:
|
||||
better-sqlite3:
|
||||
specifier: 9.4.5
|
||||
version: 9.4.5
|
||||
binary-parser:
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
bms-table-loader:
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
@@ -4398,6 +4401,11 @@ packages:
|
||||
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
/binary-parser@2.2.1:
|
||||
resolution: {integrity: sha512-5ATpz/uPDgq5GgEDxTB4ouXCde7q2lqAQlSdBRQVl/AJnxmQmhIfyxJx+0MGu//D5rHQifkfGbWWlaysG0o9NA==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
/binary@0.3.0:
|
||||
resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==}
|
||||
dependencies:
|
||||
|
||||
+17626
-8917
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,11 +15,13 @@
|
||||
"tsconfig-paths": "3.12.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "18.11.18",
|
||||
"@types/lodash.get": "^4.4.7",
|
||||
"@types/node": "18.11.18",
|
||||
"better-sqlite3": "9.4.5",
|
||||
"binary-parser": "^2.2.1",
|
||||
"bms-table-loader": "2.0.0",
|
||||
"chalk": "4",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"commander": "^8.2.0",
|
||||
"csv-parse": "^5.0.3",
|
||||
"deepmerge": "^4.2.2",
|
||||
@@ -34,7 +36,6 @@
|
||||
"monk": "^7.3.4",
|
||||
"node-fetch": "2.6.7",
|
||||
"prudence": "0.10.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"tachi-common": "workspace:../../common"
|
||||
}
|
||||
}
|
||||
@@ -28,3 +28,10 @@ File must be stored in the same folder as the script with the name `musicdb.xml`
|
||||
|
||||
2) At the last line of the script, eventually change the version if necessary. Current supported versions are `a3` and `konaste` (which are very similar since konaste is based on a3)
|
||||
3) Run `ts-node parse-gameData-xml.ts`
|
||||
|
||||
# Update stepCounts
|
||||
|
||||
Step counts are generated from SSQ files. You're on your own to find them.
|
||||
|
||||
1) Put all your SSQ files in the `ssq` folder. The name of each file should correspond to the `basename` of each song (a 4~5 characters identifier)
|
||||
2) Run `ts-node parse-charts.ts`
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import fs from "fs";
|
||||
import {Parser} from "binary-parser";
|
||||
import {ReadCollection, WriteCollection} from "../../util";
|
||||
import {ChartDocument, SongDocument} from "tachi-common";
|
||||
|
||||
const STEP_HEADER_LENGTH = 6;
|
||||
|
||||
class StepData {
|
||||
style: string;
|
||||
difficulty: string;
|
||||
steps: number;
|
||||
|
||||
constructor(style: string, difficulty: string, steps: number) {
|
||||
this.style = style;
|
||||
this.difficulty = difficulty;
|
||||
this.steps = steps;
|
||||
}
|
||||
}
|
||||
|
||||
class SSQParser {
|
||||
private TempoChanges = new Parser()
|
||||
.endianness("little")
|
||||
.uint16("ticksPerSecond")
|
||||
.uint16("offsetCount")
|
||||
.uint16("tempoIgnoredParam")
|
||||
.array("timeOffsets", {
|
||||
type: "int32le",
|
||||
length: "offsetCount",
|
||||
})
|
||||
.array("data", {
|
||||
type: "int32le",
|
||||
length: "offsetCount",
|
||||
});
|
||||
|
||||
private Configurations = new Parser()
|
||||
.endianness("little")
|
||||
.uint16("configIgnoredParam1")
|
||||
.uint16("configCount")
|
||||
.uint16("configIgnoredParam2")
|
||||
.array("configOffsets", {
|
||||
type: "uint32le",
|
||||
length: "configCount",
|
||||
})
|
||||
.array("data", {
|
||||
type: "uint16le",
|
||||
length: "configCount",
|
||||
});
|
||||
|
||||
private StepsExtraData = new Parser().endianness("little").uint8("panels").uint8("type");
|
||||
|
||||
private Steps = new Parser().endianness("little").wrapped("buffer", {
|
||||
length: function (this: any, item) {
|
||||
return this.$parent.size - STEP_HEADER_LENGTH;
|
||||
},
|
||||
wrapper: (buffer) => buffer,
|
||||
type: new Parser()
|
||||
.endianness("little")
|
||||
.uint8("style")
|
||||
.uint8("difficulty")
|
||||
.uint16("stepCount")
|
||||
.uint16("stepsIgnoredParam")
|
||||
.array("measures", {
|
||||
type: "uint32le",
|
||||
length: "stepCount",
|
||||
})
|
||||
.array("steps", {
|
||||
type: "uint8",
|
||||
length: "stepCount",
|
||||
})
|
||||
.buffer("ignoredBufferBeforeExtraData", {
|
||||
readUntil: (item, buffer) => {
|
||||
return buffer[0] !== 0;
|
||||
},
|
||||
})
|
||||
.array("stepsExtraData", {
|
||||
type: this.StepsExtraData,
|
||||
readUntil: "eof",
|
||||
}),
|
||||
});
|
||||
private Ignored = new Parser().endianness("little").seek(function (this: any, item) {
|
||||
return this.$parent.size - STEP_HEADER_LENGTH;
|
||||
});
|
||||
private EndOfFile = new Parser().endianness("little").array("end", {
|
||||
type: "uint8",
|
||||
readUntil: "eof",
|
||||
});
|
||||
|
||||
private Chunk = new Parser()
|
||||
.useContextVars()
|
||||
.endianness("little")
|
||||
.uint32("size")
|
||||
.uint16("chunkType")
|
||||
.choice("content", {
|
||||
tag: "chunkType",
|
||||
choices: {
|
||||
0: this.EndOfFile, // Hacky way to ignore end of file, probably
|
||||
1: this.TempoChanges, // TempoChanges
|
||||
2: this.Configurations, // Configurations
|
||||
3: this.Steps, // Steps. What we really need.
|
||||
4: this.Ignored, // Background
|
||||
5: this.Ignored, // Unknown
|
||||
9: this.Ignored, // SongMetadata
|
||||
17: this.Ignored, // Wtf is that
|
||||
},
|
||||
});
|
||||
|
||||
private debugFormatter(item: any) {
|
||||
console.dir(item, {maxArrayLength: null, depth: 4});
|
||||
return item;
|
||||
}
|
||||
|
||||
private ssqFile = new Parser().endianness("little").useContextVars().array("chunks", {
|
||||
type: this.Chunk,
|
||||
readUntil: function (item, buffer) {
|
||||
return buffer.length <= 6;
|
||||
},
|
||||
});
|
||||
|
||||
private getStyle(style: number) {
|
||||
switch (style) {
|
||||
case 20:
|
||||
return "SP";
|
||||
case 24:
|
||||
return "DP";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private getDifficulty(difficulty: number) {
|
||||
switch (difficulty) {
|
||||
case 1:
|
||||
return "BASIC";
|
||||
case 2:
|
||||
return "DIFFICULT";
|
||||
case 3:
|
||||
return "EXPERT";
|
||||
case 4:
|
||||
return "BEGINNER";
|
||||
case 6:
|
||||
return "CHALLENGE";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
parse(filename: string): StepData[] {
|
||||
const fileContent = fs.readFileSync(filename, "hex");
|
||||
const byteArray = new Uint8Array(
|
||||
fileContent!.match(/.{1,2}/g)!.map((str) => parseInt(str, 16))
|
||||
);
|
||||
const output = this.ssqFile.parse(byteArray);
|
||||
return output.chunks
|
||||
.filter((chunk: any) => chunk.chunkType === 3)
|
||||
.map((chunk: any) => {
|
||||
return new StepData(this.getStyle(chunk.content.buffer.style), this.getDifficulty(chunk.content.buffer.difficulty), chunk.content.buffer.steps.filter(
|
||||
(step: number, i: number, arr: number[]) =>
|
||||
step !== 0 || step === 0 && i > 0 && arr[i - 1] !== 0
|
||||
).length);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class DataUpdater {
|
||||
songs = ReadCollection("songs-ddr.json");
|
||||
existingChartDocs: ChartDocument<"ddr:SP" | "ddr:DP">[] = ReadCollection("charts-ddr.json");
|
||||
|
||||
updateStepCounts(basename: string, stepDataArray: StepData[]) {
|
||||
for (const stepData of stepDataArray) {
|
||||
const song: SongDocument<"ddr"> = this.songs.find(
|
||||
(s: SongDocument<"ddr">) => s.data.basename === basename
|
||||
);
|
||||
if (!song) break;
|
||||
for (const chart of this.existingChartDocs) {
|
||||
if (song.id === chart.songID && chart.playtype === stepData.style && chart.difficulty === stepData.difficulty) {
|
||||
console.log(`Mutating chart ${chart.chartID}...`);
|
||||
chart.data.stepCount = stepData.steps;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveCharts() {
|
||||
// overwrite this collection instead of mutating it
|
||||
// we already know the existing chart docs and might have mutated them to
|
||||
// declare the new versions, or update chart constants.
|
||||
WriteCollection("charts-ddr.json", [...this.existingChartDocs]);
|
||||
}
|
||||
}
|
||||
|
||||
const ssqParser = new SSQParser();
|
||||
const dataUpdater = new DataUpdater();
|
||||
|
||||
fs.readdirSync("./ssq/").filter((file) => file.endsWith(".ssq")).forEach((file) => {
|
||||
console.log(`Parsing ${file}...`)
|
||||
try {
|
||||
const stepData = ssqParser.parse(`./ssq/${file}`);
|
||||
dataUpdater.updateStepCounts(file.replace(".ssq", ""), stepData);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
})
|
||||
|
||||
dataUpdater.saveCharts();
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ function buildSong(music: Music): SongDocument<"ddr"> {
|
||||
data: {
|
||||
inGameID: music.mcode,
|
||||
flareCategory: seriesToFlareCategory(music.series),
|
||||
basename: music.basename,
|
||||
},
|
||||
altTitles: [],
|
||||
searchTerms: [],
|
||||
@@ -108,6 +109,9 @@ export function parseGameData(version: Versions["ddr:SP" | "ddr:DP"], gameData:
|
||||
);
|
||||
newSongs.push(buildSong(music));
|
||||
}
|
||||
else {
|
||||
song.data.basename = music.basename;
|
||||
}
|
||||
const splitDiff = music.diffLv.split(" ");
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (parseInt(splitDiff[i]!, 10) > 0) {
|
||||
@@ -135,13 +139,10 @@ export function parseGameData(version: Versions["ddr:SP" | "ddr:DP"], gameData:
|
||||
}
|
||||
}
|
||||
|
||||
MutateCollection("songs-ddr.json", (songs: Array<SongDocument<"ddr">>) => [
|
||||
...songs,
|
||||
...newSongs,
|
||||
]);
|
||||
// overwrite the collections instead of mutating them
|
||||
// we already know the existing docs and might have mutated them to
|
||||
// declare the new versions, or update constants.
|
||||
|
||||
// overwrite this collection instead of mutating it
|
||||
// we already know the existing chart docs and might have mutated them to
|
||||
// declare the new versions, or update chart constants.
|
||||
WriteCollection("songs-ddr.json", [...songs, ...newSongs]);
|
||||
WriteCollection("charts-ddr.json", [...existingChartDocs, ...newCharts]);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
GoalFmtPercent,
|
||||
GoalFmtScore,
|
||||
GoalOutOfFmtPercent,
|
||||
GoalOutOfFmtScore,
|
||||
GradeGoalFormatter,
|
||||
} from "./_common";
|
||||
import { GoalFmtScore, GoalOutOfFmtScore, GradeGoalFormatter } from "./_common";
|
||||
import db from "../../external/mongo/db";
|
||||
import CreateLogCtx from "../../lib/logger/logger";
|
||||
import { IsNullish } from "../../utils/misc";
|
||||
import { CreatePBMergeFor } from "../utils/pb-merge";
|
||||
import { SessionAvgBest10For } from "../utils/session-calc";
|
||||
@@ -25,7 +20,9 @@ import type {
|
||||
GPTServerImplementation,
|
||||
ScoreValidator,
|
||||
} from "game-implementations/types";
|
||||
import type { PBScoreDocument, SongDocument } from "tachi-common";
|
||||
import type { ChartDocument, PBScoreDocument, ScoreDocument, SongDocument } from "tachi-common";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
interface PBScoreDocumentWithSong extends PBScoreDocument<"ddr:DP" | "ddr:SP"> {
|
||||
song: SongDocument<"ddr">;
|
||||
@@ -58,40 +55,38 @@ const DDR_GOAL_PG_FMT: GPTGoalProgressFormatters<"ddr:DP" | "ddr:SP"> = {
|
||||
};
|
||||
|
||||
export const DDR_SCORE_VALIDATORS: Array<ScoreValidator<"ddr:DP" | "ddr:SP">> = [
|
||||
// Score validation with judgements is disabled until we have stepCount + holdCount for every chart.
|
||||
// Using the sum of all judgements does not work in case of missed holds.
|
||||
// (s) => {
|
||||
// if (s.scoreData.lamp === "FAILED") {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// const { MARVELOUS, PERFECT, GREAT, GOOD, OK, MISS } = s.scoreData.judgements;
|
||||
//
|
||||
// if (
|
||||
// IsNullish(MARVELOUS) ||
|
||||
// IsNullish(PERFECT) ||
|
||||
// IsNullish(GREAT) ||
|
||||
// IsNullish(GOOD) ||
|
||||
// IsNullish(OK) ||
|
||||
// IsNullish(MISS)
|
||||
// ) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// const stepScore = 1_000_000 / (MARVELOUS + PERFECT + GREAT + GOOD + OK + MISS);
|
||||
// const calculatedScore =
|
||||
// Math.floor(
|
||||
// (stepScore * (MARVELOUS + OK) +
|
||||
// (stepScore - 10) * PERFECT +
|
||||
// ((stepScore * 3) / 5 - 10) * GREAT +
|
||||
// (stepScore / 5 - 10) * GOOD) /
|
||||
// 10
|
||||
// ) * 10;
|
||||
//
|
||||
// if (calculatedScore !== s.scoreData.score) {
|
||||
// return `Expected calculated score from judgements of ${calculatedScore} to equal score of ${s.scoreData.score}.`;
|
||||
// }
|
||||
// },
|
||||
(s: ScoreDocument<"ddr:DP" | "ddr:SP">, chart?: ChartDocument<"ddr:DP" | "ddr:SP">) => {
|
||||
if (s.scoreData.lamp === "FAILED" || !chart || IsNullish(chart.data.stepCount)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { MARVELOUS, PERFECT, GREAT, GOOD, OK, MISS } = s.scoreData.judgements;
|
||||
|
||||
if (
|
||||
IsNullish(MARVELOUS) ||
|
||||
IsNullish(PERFECT) ||
|
||||
IsNullish(GREAT) ||
|
||||
IsNullish(GOOD) ||
|
||||
IsNullish(OK) ||
|
||||
IsNullish(MISS)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stepScore = 1_000_000 / chart.data.stepCount;
|
||||
const calculatedScore =
|
||||
Math.floor(
|
||||
(stepScore * (MARVELOUS + OK) +
|
||||
(stepScore - 10) * PERFECT +
|
||||
((stepScore * 3) / 5 - 10) * GREAT +
|
||||
(stepScore / 5 - 10) * GOOD) /
|
||||
10
|
||||
) * 10;
|
||||
|
||||
if (calculatedScore !== s.scoreData.score) {
|
||||
return `Expected calculated score from judgements of ${calculatedScore} to equal score of ${s.scoreData.score}.`;
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
const { MARVELOUS, PERFECT, GREAT, GOOD, MISS } = s.scoreData.judgements;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user