import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { DAMAGE_RECEIVERS } from "~/features/object-damage-calculator/calculator-constants"; import type { MainWeaponId, SpecialWeaponId, SubWeaponId, } from "~/modules/in-game-lists/types"; import { mainWeaponIds, specialWeaponIds, subWeaponIds, } from "~/modules/in-game-lists/weapon-ids"; // To run this script drop the https://github.com/Leanny/splat3 repo into scripts/dicts/splat3 import { loadDamageRateInfo, loadWeaponInfoMain, loadWeaponInfoSpecial, loadWeaponInfoSub, PARAMETER_DIR, } from "./utils"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const params = loadDamageRateInfo(); const weapons = loadWeaponInfoMain(); const subWeapons = loadWeaponInfoSub(); const specialWeapons = loadWeaponInfoSpecial(); const OBJECT_DMG_OUTPUT_PATH = path.join( __dirname, "..", "app", "features", "object-damage-calculator", "data", "object-dmg.json", ); const DAMAGE_RATE_CONFIG_FILE_NAME = "spl__DamageRateInfoConfig.pp__CombinationDataTableData.json"; const HISTORY_OUTPUT_PATH = path.join( __dirname, "..", "app", "features", "params", "data", "damage-rate-history.json", ); type DamageReceiver = (typeof DAMAGE_RECEIVERS)[number]; type ResultEntry = { mainWeaponIds: MainWeaponId[]; subWeaponIds: SubWeaponId[]; specialWeaponIds: SpecialWeaponId[]; rates: { target: string; rate: number }[]; }; type DamageRateCell = { ColumnKey: string; RowKey: string; DamageRate?: number; }; type DamageRateConfig = { CellList: Record }; type TargetHistory = { target: string; current: number; history: Array<{ version: string; value: number }>; }; type DamageRateHistoryRow = { mainWeaponIds: MainWeaponId[]; subWeaponIds: SubWeaponId[]; specialWeaponIds: SpecialWeaponId[]; targets: TargetHistory[]; }; const weaponParamsToWeaponIds = ( params: typeof weapons | typeof subWeapons | typeof specialWeapons, key: string, ) => { return params .filter((param) => { return ( param.DefaultDamageRateInfoRow === key || param.ExtraDamageRateInfoRowSet?.some( (row) => row.DamageRateInfoRow === key, ) ); }) .map((weapon) => weapon.Id); }; const isDamageReceiver = (key: string): key is DamageReceiver => (DAMAGE_RECEIVERS as readonly string[]).includes(key); const mainIdsForRow = (rowKey: string) => weaponParamsToWeaponIds(weapons, rowKey).filter((id): id is MainWeaponId => (mainWeaponIds as readonly number[]).includes(id), ); const subIdsForRow = (rowKey: string) => weaponParamsToWeaponIds(subWeapons, rowKey).filter((id): id is SubWeaponId => (subWeaponIds as readonly number[]).includes(id), ); const specialIdsForRow = (rowKey: string) => weaponParamsToWeaponIds(specialWeapons, rowKey).filter( (id): id is SpecialWeaponId => (specialWeaponIds as readonly number[]).includes(id), ); /** * Resolves the per-target damage rate of every damage rate info row in a single config dump. * Only the PvP-relevant receivers are kept and the synthetic launched/Recycled Brella canopy * targets are derived the same way the live object damage calculator expects them. */ const damageRatesByRow = ( config: DamageRateConfig, ): Map> => { const result = new Map>(); for (const cell of Object.values(config.CellList)) { if (!isDamageReceiver(cell.ColumnKey)) continue; if (!cell.DamageRate) continue; let row = result.get(cell.RowKey); if (!row) { row = new Map(); result.set(cell.RowKey, row); } row.set(cell.ColumnKey, cell.DamageRate); // launched versions have double health but share the same rate if ( cell.ColumnKey.includes("BulletUmbrellaCanopyNormal") || cell.ColumnKey.includes("BulletUmbrellaCanopyWide") ) { row.set(`${cell.ColumnKey}_Launched`, cell.DamageRate); } // Recycled Brella reuses Splat Brella's special damage rates if (cell.ColumnKey === "BulletUmbrellaCanopyNormal") { row.set("BulletShelterCanopyFocus", cell.DamageRate); row.set("BulletShelterCanopyFocus_Launched", cell.DamageRate); } } return result; }; const result: Record = {}; for (const [rowKey, rates] of damageRatesByRow(params)) { const mainWeaponIdsForRow = mainIdsForRow(rowKey); const subWeaponIdsForRow = subIdsForRow(rowKey); const specialWeaponIdsForRow = specialIdsForRow(rowKey); // if it applies to no PvP weapons, we don't care about it if ( mainWeaponIdsForRow.length === 0 && subWeaponIdsForRow.length === 0 && specialWeaponIdsForRow.length === 0 && rowKey !== "ObjectEffect_Up" ) { continue; } result[rowKey] = { mainWeaponIds: mainWeaponIdsForRow, subWeaponIds: subWeaponIdsForRow, specialWeaponIds: specialWeaponIdsForRow, rates: [...rates].map(([target, rate]) => ({ target, rate })), }; } fs.writeFileSync(OBJECT_DMG_OUTPUT_PATH, JSON.stringify(result, null, 2)); writeDamageRateHistory(); function versionDirToDisplay(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}`; } /** * Builds the per-row, per-target damage rate history across every versioned config dump and * writes it for the params page to surface in its patch history. Only PvP-relevant rows and * only targets whose rate actually changed at some point are kept, so the output stays small. */ function writeDamageRateHistory() { const versionDirs = fs .readdirSync(PARAMETER_DIR) .filter((dir) => /^\d+$/.test(dir)) .sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10)); const ratesByVersion = new Map>>(); for (const version of versionDirs) { const filePath = path.join( PARAMETER_DIR, version, "misc", DAMAGE_RATE_CONFIG_FILE_NAME, ); if (!fs.existsSync(filePath)) continue; const config = JSON.parse( fs.readFileSync(filePath, "utf8"), ) as DamageRateConfig; ratesByVersion.set(version, damageRatesByRow(config)); } const presentVersions = versionDirs.filter((version) => ratesByVersion.has(version), ); const latestVersion = presentVersions[presentVersions.length - 1]; const rows: Record = {}; for (const [rowKey, latestRates] of ratesByVersion.get(latestVersion) ?? []) { const mainWeaponIdsForRow = mainIdsForRow(rowKey); const subWeaponIdsForRow = subIdsForRow(rowKey); const specialWeaponIdsForRow = specialIdsForRow(rowKey); if ( mainWeaponIdsForRow.length === 0 && subWeaponIdsForRow.length === 0 && specialWeaponIdsForRow.length === 0 ) { continue; } const targets: TargetHistory[] = []; for (const [target, current] of latestRates) { const presentForTarget = presentVersions.filter( (version) => ratesByVersion.get(version)?.get(rowKey)?.get(target) !== undefined, ); const history: Array<{ version: string; value: number }> = []; for (let i = 0; i < presentForTarget.length - 1; i++) { const value = ratesByVersion .get(presentForTarget[i])! .get(rowKey)! .get(target)!; const nextValue = ratesByVersion .get(presentForTarget[i + 1])! .get(rowKey)! .get(target)!; if (value !== nextValue) { history.push({ version: versionDirToDisplay(presentForTarget[i]), value, }); } } if (history.length > 0) { targets.push({ target, current, history }); } } if (targets.length > 0) { rows[rowKey] = { mainWeaponIds: mainWeaponIdsForRow, subWeaponIds: subWeaponIdsForRow, specialWeaponIds: specialWeaponIdsForRow, targets, }; } } fs.writeFileSync( HISTORY_OUTPUT_PATH, JSON.stringify( { metadata: { versions: presentVersions.map(versionDirToDisplay) }, rows, }, null, 2, ), ); }