272 lines
8.3 KiB
JavaScript
272 lines
8.3 KiB
JavaScript
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_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 <snapshot.json> --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 !== 4 ||
|
|
!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.',
|
|
);
|
|
}
|
|
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}`);
|