import { connect } from '@tursodatabase/database'; import { spawnSync } from 'node:child_process'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, join, resolve } from 'node:path'; import process from 'node:process'; import { ISAC_DATA_FORMAT_VERSION, ISAC_DATA_TABLES, quoteIdentifier, } from './isac-data-tables.mjs'; const snapshotArgument = process.argv .slice(2) .find((argument) => !argument.startsWith('--')); if ( !snapshotArgument || !process.argv.includes('--confirm-replace-isac-data') ) { console.error( 'Usage: node scripts/isac-data-import.mjs --confirm-replace-isac-data', ); process.exit(2); } const snapshotPath = resolve(snapshotArgument); const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8')); if ( snapshot?.format !== 'shd-build-isac-data' || snapshot?.version !== ISAC_DATA_FORMAT_VERSION || !Array.isArray(snapshot.tables) || !Array.isArray(snapshot.icons) ) { throw new Error( 'The selected file is not a supported SHD.build ISAC data snapshot.', ); } const receivedNames = snapshot.tables.map((table) => table.name); if ( receivedNames.length !== ISAC_DATA_TABLES.length || ISAC_DATA_TABLES.some((name, index) => receivedNames[index] !== name) ) { throw new Error( 'The ISAC snapshot table manifest is incomplete or out of order.', ); } const abilityRows = snapshot.tables.find( (table) => table.name === 'specialization_abilities', ).rows; const abilitiesById = new Map( abilityRows.map((row) => [String(row.id ?? ''), row]), ); const skillChipRows = snapshot.tables.find( (table) => table.name === 'skill_chip_sets', ).rows; const activeSkillChipCounts = new Map(); for (const row of skillChipRows) { const abilityId = String(row.specialization_ability_id ?? ''); if (!abilityId || !abilitiesById.has(abilityId)) { throw new Error( `The ISAC snapshot contains a Skill Chip definition with a missing ability reference "${abilityId}".`, ); } if (Number(row.active) !== 1) continue; const count = (activeSkillChipCounts.get(abilityId) ?? 0) + 1; if (count > 3) { throw new Error( `The ISAC snapshot assigns more than three active Skill Chip definitions to ability "${abilityId}".`, ); } activeSkillChipCounts.set(abilityId, count); } const skillChipLinks = snapshot.tables.find( (table) => table.name === 'specialization_ability_skill_chip_links', ).rows; const linkedAbilityIds = new Set(); for (const row of skillChipLinks) { const abilityId = row.ability_id; const compatibleAbilityId = row.compatible_ability_id; if ( typeof abilityId !== 'string' || !abilityId || typeof compatibleAbilityId !== 'string' || !compatibleAbilityId ) { throw new Error( 'The ISAC snapshot contains a Skill Chip sharing link with malformed ability references.', ); } if (abilityId === compatibleAbilityId) { throw new Error( `The ISAC snapshot contains a self-referencing Skill Chip sharing link for ability "${abilityId}".`, ); } if (abilityId >= compatibleAbilityId) { throw new Error( `The ISAC snapshot contains a Skill Chip sharing link that is not in canonical ability order: "${abilityId}", "${compatibleAbilityId}".`, ); } const ability = abilitiesById.get(abilityId); const compatibleAbility = abilitiesById.get(compatibleAbilityId); if (!ability || !compatibleAbility) { throw new Error( `The ISAC snapshot contains a Skill Chip sharing link with a missing ability reference: "${abilityId}", "${compatibleAbilityId}".`, ); } if (Number(ability.active) !== 1 || Number(compatibleAbility.active) !== 1) { throw new Error( `The ISAC snapshot contains a Skill Chip sharing link with an inactive ability: "${abilityId}", "${compatibleAbilityId}".`, ); } if (ability.specialization_id !== compatibleAbility.specialization_id) { throw new Error( `The ISAC snapshot contains a cross-specialization Skill Chip sharing link: "${abilityId}", "${compatibleAbilityId}".`, ); } if ( linkedAbilityIds.has(abilityId) || linkedAbilityIds.has(compatibleAbilityId) ) { throw new Error( 'The ISAC snapshot contains more than one Skill Chip sharing link touching an ability.', ); } linkedAbilityIds.add(abilityId); linkedAbilityIds.add(compatibleAbilityId); } for (const kind of ['weapon', 'gear']) { const options = snapshot.tables.find( (table) => table.name === `${kind}_mod_secondary_options`, ).rows; const values = snapshot.tables.find( (table) => table.name === `${kind}_mod_secondary_quality_values`, ).rows; const optionIds = options.map((row) => String(row.stat_attribute_id ?? '')); if ( optionIds.some((id) => !id) || new Set(optionIds).size !== optionIds.length || (optionIds.length !== 0 && (optionIds.length < 2 || optionIds.length > 50)) || values.length !== optionIds.length * 3 ) { throw new Error( `The ISAC snapshot contains an incomplete shared ${kind} Mod secondary pool.`, ); } for (const statId of optionIds) { const profile = values.filter( (row) => String(row.stat_attribute_id) === statId, ); if ( profile.length !== 3 || ['Specialized', 'Superior', 'High-end'].some( (quality) => !profile.some((row) => row.quality === quality), ) || profile.some((row) => { const endpoints = [ row.level_1_min, row.level_1_max, row.level_20_min, row.level_20_max, ]; return ( endpoints.some( (value) => typeof value !== 'number' || !Number.isFinite(value), ) || endpoints[1] < endpoints[0] || endpoints[2] < endpoints[0] || endpoints[3] < endpoints[1] || endpoints[3] < endpoints[2] ); }) ) { throw new Error( `The ISAC snapshot contains an invalid shared ${kind} Mod secondary profile for ${statId}.`, ); } } if ( values.some( (row) => !optionIds.includes(String(row.stat_attribute_id ?? '')), ) ) { throw new Error( `The ISAC snapshot contains an orphaned shared ${kind} Mod secondary range.`, ); } } for (const [profileName, qualities, requiredOptions] of [ ['primary', ['Standard', 'Specialized', 'Superior', 'High-end'], 4], ['secondary', ['Specialized', 'Superior', 'High-end'], null], ]) { const options = snapshot.tables.find( (table) => table.name === `skill_chip_${profileName}_options`, ).rows; const values = snapshot.tables.find( (table) => table.name === `skill_chip_${profileName}_quality_values`, ).rows; const optionIds = options.map((row) => String(row.stat_attribute_id ?? '')); const validCardinality = requiredOptions ? optionIds.length === requiredOptions : optionIds.length <= 50; if ( !validCardinality || optionIds.some((id) => !id) || new Set(optionIds).size !== optionIds.length ) { throw new Error( `The ISAC snapshot contains an incomplete shared Skill Chip ${profileName} profile.`, ); } if (profileName === 'primary' && optionIds.length) { const attributes = snapshot.tables.find( (table) => table.name === 'stat_attributes', ).rows; const expectedIds = attributes .filter( (row) => Number(row.active) === 1 && [ 'Skill Duration', 'Skill Radius', 'Skill Cooldown Recovery', 'Skill Intensity', ].includes(String(row.name)), ) .map((row) => String(row.id)) .sort(); if ( expectedIds.length !== 4 || optionIds .slice() .sort() .some((id, index) => id !== expectedIds[index]) ) throw new Error( 'The ISAC snapshot does not use the four fixed Skill Chip primary Attributes.', ); } for (const statId of optionIds) { const rows = values.filter( (row) => String(row.stat_attribute_id ?? '') === statId, ); if (!rows.length) continue; if ( rows.length !== qualities.length || qualities.some( (quality) => !rows.some((row) => row.quality === quality), ) || rows.some( (row) => typeof row.min_value !== 'number' || !Number.isFinite(row.min_value) || typeof row.max_value !== 'number' || !Number.isFinite(row.max_value) || row.min_value < 0 || row.max_value < row.min_value, ) ) { throw new Error( `The ISAC snapshot contains an invalid shared Skill Chip ${profileName} profile for ${statId}.`, ); } } if ( values.some( (row) => !optionIds.includes(String(row.stat_attribute_id ?? '')), ) ) throw new Error( `The ISAC snapshot contains an orphaned shared Skill Chip ${profileName} range.`, ); } for (const icon of snapshot.icons) { if ( typeof icon.storageKey !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(icon.storageKey) || typeof icon.contentBase64 !== 'string' ) { throw new Error('The ISAC snapshot contains an invalid uploaded icon.'); } } const timestamp = new Date() .toISOString() .replaceAll(':', '-') .replace(/\.\d{3}Z$/, 'Z'); const databasePath = resolve( process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', ); const backupPath = resolve( `./data/backups/${basename(databasePath).replace(/\.[^.]+$/, '')}-before-isac-import-${timestamp}.json`, ); const isacBackupPath = resolve( `./data/backups/isac-data-before-import-${timestamp}.json`, ); for (const [script, output] of [ ['scripts/db-backup.mjs', backupPath], ['scripts/isac-data-export.mjs', isacBackupPath], ]) { const result = spawnSync(process.execPath, [script, output], { cwd: process.cwd(), stdio: 'inherit', env: process.env, }); if (result.error) throw result.error; if (result.status !== 0) throw new Error( `Safety backup failed in ${script}; no ISAC data was changed.`, ); } const database = await connect(databasePath); let importedRows = 0; await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { for (const name of [...ISAC_DATA_TABLES].reverse()) { await database.exec(`DELETE FROM ${quoteIdentifier(name)}`); } for (const table of snapshot.tables) { for (const row of table.rows) { const columns = Object.keys(row); if (!columns.length) continue; const insert = await database.prepare( `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`, ); await insert.run(...columns.map((column) => row[column])); importedRows++; } } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } const iconDirectory = resolve( process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons', ); await mkdir(iconDirectory, { recursive: true }); for (const icon of snapshot.icons) { await writeFile( join(iconDirectory, icon.storageKey), Buffer.from(icon.contentBase64, 'base64'), { mode: 0o600 }, ); } console.log( `Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded image assets from ${snapshotPath}.`, ); console.log(`Full pre-import backup: ${backupPath}`); console.log(`ISAC pre-import snapshot: ${isacBackupPath}`);