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 { const map = new Map(); const seenFileNames = new Set(); 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, ): Map { const map = new Map(); const seenFileNames = new Set(); 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 = {}; 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, 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; } 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)[part]; } return current; } function mergeWithHistory( latestParams: Record, allVersionParams: Map>, sortedVersions: string[], ): Record { const result: Record = 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> { const result = new Map>(); const mainWeaponIdSet = new Set(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>, 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, sortedVersions: string[], ): Record> { const allVersions = new Map>>(); 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> = {}; 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));