Files
shd.build/scripts/isac-data-import.mjs
T
jb 57068b61cd
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
feat: expand ISAC content modeling
2026-08-03 01:23:20 -04:00

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}`);