mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-06-21 12:03:31 -05:00
441 lines
12 KiB
TypeScript
441 lines
12 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
mainWeaponIds,
|
|
specialWeaponIds,
|
|
subWeaponIds,
|
|
} from "~/modules/in-game-lists/weapon-ids";
|
|
import { logger } from "~/utils/logger";
|
|
import {
|
|
loadWeaponInfoMain,
|
|
loadWeaponInfoSpecial,
|
|
loadWeaponInfoSub,
|
|
MUSH_DIR,
|
|
PARAMETER_DIR,
|
|
} from "./utils";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const weapons = loadWeaponInfoMain();
|
|
const subWeapons = loadWeaponInfoSub();
|
|
const specialWeapons = loadWeaponInfoSpecial();
|
|
|
|
const OUTPUT_DIR = path.join(
|
|
__dirname,
|
|
"..",
|
|
"app",
|
|
"features",
|
|
"params",
|
|
"data",
|
|
);
|
|
const OUTPUT_FILE = path.join(OUTPUT_DIR, "all-version-weapon-params.json");
|
|
const SUB_OUTPUT_FILE = path.join(OUTPUT_DIR, "all-version-sub-params.json");
|
|
const SPECIAL_OUTPUT_FILE = path.join(
|
|
OUTPUT_DIR,
|
|
"all-version-special-params.json",
|
|
);
|
|
|
|
const WEAPON_TYPES_TO_IGNORE = [
|
|
"Mission",
|
|
"Coop",
|
|
"Hero",
|
|
"Rival",
|
|
"SalmonBuddy",
|
|
"Sdodr",
|
|
];
|
|
|
|
type MainWeapon = (typeof weapons)[number];
|
|
|
|
function parseVersionToDisplay(version: string): string {
|
|
const num = Number.parseInt(version, 10);
|
|
const major = Math.floor(num / 100);
|
|
const minor = Math.floor((num % 100) / 10);
|
|
const patch = num % 10;
|
|
return `${major}.${minor}.${patch}`;
|
|
}
|
|
|
|
function sortVersions(versions: string[]): string[] {
|
|
return versions.sort(
|
|
(a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10),
|
|
);
|
|
}
|
|
|
|
function mainWeaponShouldBeSkipped(mainWeapon: MainWeapon): boolean {
|
|
return WEAPON_TYPES_TO_IGNORE.includes(mainWeapon.Type);
|
|
}
|
|
|
|
function weaponRowIdToFileName(rowId: string): string {
|
|
const parts = rowId.split("_");
|
|
const category = parts[0];
|
|
const codeName = parts[1];
|
|
return `Weapon${category}${codeName ?? ""}.game__GameParameterTable.json`;
|
|
}
|
|
|
|
function buildWeaponFileNameToIdMap(): Map<string, number> {
|
|
const map = new Map<string, number>();
|
|
const seenFileNames = new Set<string>();
|
|
|
|
for (const weapon of weapons) {
|
|
if (mainWeaponShouldBeSkipped(weapon)) continue;
|
|
|
|
const fileName = weaponRowIdToFileName(weapon.__RowId);
|
|
|
|
if (!seenFileNames.has(fileName)) {
|
|
seenFileNames.add(fileName);
|
|
map.set(fileName, weapon.Id);
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
// Sub and special weapons share the per-version `weapon` GameParameterTable dump with main
|
|
// weapons, so only the canonical "Versus" entry of each id is kept (Hero/Mission/etc. variants
|
|
// of the same weapon are ignored).
|
|
function buildSubOrSpecialFileNameToIdMap(
|
|
entries: Array<{ Id: number; __RowId: string; Type: string }>,
|
|
allowedIds: ReadonlySet<number>,
|
|
): Map<string, number> {
|
|
const map = new Map<string, number>();
|
|
const seenFileNames = new Set<string>();
|
|
|
|
for (const entry of entries) {
|
|
if (entry.Type !== "Versus") continue;
|
|
if (!allowedIds.has(entry.Id)) continue;
|
|
|
|
const fileName = weaponRowIdToFileName(entry.__RowId);
|
|
|
|
if (!seenFileNames.has(fileName)) {
|
|
seenFileNames.add(fileName);
|
|
map.set(fileName, entry.Id);
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
function stripTypeFields(obj: unknown): unknown {
|
|
if (obj === null || typeof obj !== "object") {
|
|
return obj;
|
|
}
|
|
|
|
if (Array.isArray(obj)) {
|
|
return obj.map(stripTypeFields);
|
|
}
|
|
|
|
const result: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (key === "$type") continue;
|
|
result[key] = stripTypeFields(value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function deepEqual(a: unknown, b: unknown): boolean {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
}
|
|
|
|
function getLeafPaths(
|
|
obj: unknown,
|
|
prefix = "",
|
|
): Array<{ path: string; value: unknown }> {
|
|
const results: Array<{ path: string; value: unknown }> = [];
|
|
|
|
if (obj === null || typeof obj !== "object") {
|
|
return [{ path: prefix, value: obj }];
|
|
}
|
|
|
|
if (Array.isArray(obj)) {
|
|
return [{ path: prefix, value: obj }];
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
const newPath = prefix ? `${prefix}.${key}` : key;
|
|
|
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
results.push({ path: newPath, value });
|
|
} else {
|
|
results.push(...getLeafPaths(value, newPath));
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function setNestedValueWithVersionedKey(
|
|
obj: Record<string, unknown>,
|
|
pathStr: string,
|
|
version: string,
|
|
value: unknown,
|
|
): void {
|
|
const parts = pathStr.split(".");
|
|
let current = obj;
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const part = parts[i];
|
|
if (!(part in current) || typeof current[part] !== "object") {
|
|
current[part] = {};
|
|
}
|
|
current = current[part] as Record<string, unknown>;
|
|
}
|
|
|
|
const lastPart = parts[parts.length - 1];
|
|
const versionedKey = `${lastPart}@${version}`;
|
|
current[versionedKey] = value;
|
|
}
|
|
|
|
function getNestedValue(obj: unknown, pathStr: string): unknown {
|
|
const parts = pathStr.split(".");
|
|
let current = obj;
|
|
|
|
for (const part of parts) {
|
|
if (current === null || typeof current !== "object") {
|
|
return undefined;
|
|
}
|
|
current = (current as Record<string, unknown>)[part];
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
function mergeWithHistory(
|
|
latestParams: Record<string, unknown>,
|
|
allVersionParams: Map<string, Record<string, unknown>>,
|
|
sortedVersions: string[],
|
|
): Record<string, unknown> {
|
|
const result: Record<string, unknown> = JSON.parse(
|
|
JSON.stringify(latestParams),
|
|
);
|
|
|
|
const latestPaths = getLeafPaths(latestParams);
|
|
|
|
for (const { path: paramPath, value: latestValue } of latestPaths) {
|
|
for (let i = sortedVersions.length - 2; i >= 0; i--) {
|
|
const version = sortedVersions[i];
|
|
const versionParams = allVersionParams.get(version);
|
|
if (!versionParams) continue;
|
|
|
|
const versionValue = getNestedValue(versionParams, paramPath);
|
|
if (versionValue === undefined) continue;
|
|
|
|
const nextVersion = sortedVersions[i + 1];
|
|
const nextParams = allVersionParams.get(nextVersion);
|
|
const nextValue = nextParams
|
|
? getNestedValue(nextParams, paramPath)
|
|
: latestValue;
|
|
|
|
if (!deepEqual(versionValue, nextValue)) {
|
|
setNestedValueWithVersionedKey(
|
|
result,
|
|
paramPath,
|
|
parseVersionToDisplay(version),
|
|
versionValue,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// SpecialPoint lives in WeaponInfoMain (kit data), which is not part of the per-version weapon
|
|
// GameParameterTable dump, so it is read per version from the local mush dir.
|
|
function collectSpecialPointsByVersion(
|
|
sortedVersions: string[],
|
|
): Map<number, Map<string, number>> {
|
|
const result = new Map<number, Map<string, number>>();
|
|
const mainWeaponIdSet = new Set<number>(mainWeaponIds);
|
|
|
|
for (const version of sortedVersions) {
|
|
const filePath = path.join(MUSH_DIR, version, "WeaponInfoMain.json");
|
|
if (!fs.existsSync(filePath)) continue;
|
|
|
|
let entries: MainWeapon[];
|
|
try {
|
|
entries = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
} catch {
|
|
logger.warn(`Failed to parse ${filePath}`);
|
|
continue;
|
|
}
|
|
|
|
for (const weapon of entries) {
|
|
if (mainWeaponShouldBeSkipped(weapon)) continue;
|
|
if (!mainWeaponIdSet.has(weapon.Id)) continue;
|
|
if (typeof weapon.SpecialPoint !== "number") continue;
|
|
|
|
if (!result.has(weapon.Id)) {
|
|
result.set(weapon.Id, new Map());
|
|
}
|
|
result.get(weapon.Id)!.set(version, weapon.SpecialPoint);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function buildSpecialPointsHistory(
|
|
specialPointsByVersion: Map<number, Map<string, number>>,
|
|
sortedVersions: string[],
|
|
): Record<
|
|
string,
|
|
{ current: number; history: Array<{ version: string; value: number }> }
|
|
> {
|
|
const result: Record<
|
|
string,
|
|
{ current: number; history: Array<{ version: string; value: number }> }
|
|
> = {};
|
|
|
|
for (const [weaponId, byVersion] of specialPointsByVersion) {
|
|
const presentVersions = sortedVersions.filter((v) => byVersion.has(v));
|
|
if (presentVersions.length === 0) continue;
|
|
|
|
const current = byVersion.get(presentVersions[presentVersions.length - 1])!;
|
|
const history: Array<{ version: string; value: number }> = [];
|
|
|
|
for (let i = 0; i < presentVersions.length - 1; i++) {
|
|
const value = byVersion.get(presentVersions[i])!;
|
|
const nextValue = byVersion.get(presentVersions[i + 1])!;
|
|
if (value !== nextValue) {
|
|
history.push({
|
|
version: parseVersionToDisplay(presentVersions[i]),
|
|
value,
|
|
});
|
|
}
|
|
}
|
|
|
|
result[String(weaponId)] = { current, history };
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Reads every per-version `weapon` GameParameterTable dump for the given files and folds the
|
|
// historical values of each weapon into its latest params using versioned (`Key@version`) keys.
|
|
function buildParamsWithHistory(
|
|
fileNameToId: Map<string, number>,
|
|
sortedVersions: string[],
|
|
): Record<string, Record<string, unknown>> {
|
|
const allVersions = new Map<number, Map<string, Record<string, unknown>>>();
|
|
|
|
for (const version of sortedVersions) {
|
|
const weaponDir = path.join(PARAMETER_DIR, version, "weapon");
|
|
if (!fs.existsSync(weaponDir)) continue;
|
|
|
|
for (const file of fs.readdirSync(weaponDir)) {
|
|
if (!fileNameToId.has(file)) continue;
|
|
|
|
const weaponId = fileNameToId.get(file)!;
|
|
const filePath = path.join(weaponDir, file);
|
|
|
|
try {
|
|
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
const params = stripTypeFields(content.GameParameters) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
|
|
if (!allVersions.has(weaponId)) {
|
|
allVersions.set(weaponId, new Map());
|
|
}
|
|
allVersions.get(weaponId)!.set(version, params);
|
|
} catch {
|
|
logger.warn(`Failed to parse ${filePath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const output: Record<string, Record<string, unknown>> = {};
|
|
const latestVersion = sortedVersions[sortedVersions.length - 1];
|
|
|
|
for (const [weaponId, versionParams] of allVersions) {
|
|
const latestParams = versionParams.get(latestVersion);
|
|
if (!latestParams) continue;
|
|
|
|
const versionsWithWeapon = sortedVersions.filter((v) =>
|
|
versionParams.has(v),
|
|
);
|
|
|
|
output[String(weaponId)] = mergeWithHistory(
|
|
latestParams,
|
|
versionParams,
|
|
versionsWithWeapon,
|
|
);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
async function main() {
|
|
logger.info("Starting weapon params sync...");
|
|
|
|
const versionDirs = fs
|
|
.readdirSync(PARAMETER_DIR)
|
|
.filter((dir) => /^\d+$/.test(dir));
|
|
const sortedVersions = sortVersions(versionDirs);
|
|
|
|
logger.info(
|
|
`Found ${sortedVersions.length} versions: ${sortedVersions.map(parseVersionToDisplay).join(", ")}`,
|
|
);
|
|
|
|
const latestVersion = sortedVersions[sortedVersions.length - 1];
|
|
const metadata = {
|
|
generatedAt: new Date().toISOString(),
|
|
latestVersion: parseVersionToDisplay(latestVersion),
|
|
versions: sortedVersions.map(parseVersionToDisplay),
|
|
};
|
|
|
|
const weaponFileNameToId = buildWeaponFileNameToIdMap();
|
|
logger.info(`Processing ${weaponFileNameToId.size} unique weapons`);
|
|
const outputWeapons = buildParamsWithHistory(
|
|
weaponFileNameToId,
|
|
sortedVersions,
|
|
);
|
|
|
|
const specialPoints = buildSpecialPointsHistory(
|
|
collectSpecialPointsByVersion(sortedVersions),
|
|
sortedVersions,
|
|
);
|
|
|
|
const outputSubWeapons = buildParamsWithHistory(
|
|
buildSubOrSpecialFileNameToIdMap(subWeapons, new Set(subWeaponIds)),
|
|
sortedVersions,
|
|
);
|
|
|
|
const outputSpecialWeapons = buildParamsWithHistory(
|
|
buildSubOrSpecialFileNameToIdMap(specialWeapons, new Set(specialWeaponIds)),
|
|
sortedVersions,
|
|
);
|
|
|
|
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
}
|
|
|
|
fs.writeFileSync(
|
|
OUTPUT_FILE,
|
|
JSON.stringify(
|
|
{ metadata, weapons: outputWeapons, specialPoints },
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
fs.writeFileSync(
|
|
SUB_OUTPUT_FILE,
|
|
JSON.stringify({ metadata, weapons: outputSubWeapons }, null, 2),
|
|
);
|
|
fs.writeFileSync(
|
|
SPECIAL_OUTPUT_FILE,
|
|
JSON.stringify({ metadata, weapons: outputSpecialWeapons }, null, 2),
|
|
);
|
|
|
|
logger.info(`Written to ${OUTPUT_FILE}`);
|
|
logger.info(`Total main weapons: ${Object.keys(outputWeapons).length}`);
|
|
logger.info(`Total sub weapons: ${Object.keys(outputSubWeapons).length}`);
|
|
logger.info(
|
|
`Total special weapons: ${Object.keys(outputSpecialWeapons).length}`,
|
|
);
|
|
}
|
|
|
|
main().catch((err) => logger.error(err));
|