651 lines
35 KiB
JavaScript
651 lines
35 KiB
JavaScript
import { connect } from '@tursodatabase/database';
|
|
import { access, mkdir, readFile } from 'node:fs/promises';
|
|
import { dirname, resolve } from 'node:path';
|
|
import process from 'node:process';
|
|
import { initializeDefaultUsers } from './default-users.mjs';
|
|
|
|
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
|
|
const schemaPath = resolve('./database/schema.sql');
|
|
let databaseAlreadyExisted = true;
|
|
try {
|
|
await access(databasePath);
|
|
} catch {
|
|
databaseAlreadyExisted = false;
|
|
}
|
|
|
|
await mkdir(dirname(databasePath), { recursive: true });
|
|
|
|
const database = await connect(databasePath);
|
|
const schema = await readFile(schemaPath, 'utf8');
|
|
|
|
const usersTable = await (await database.prepare(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='users'"
|
|
)).get();
|
|
if (usersTable) {
|
|
const userColumns = await (await database.prepare("PRAGMA table_info('users')")).all();
|
|
if (!userColumns.some((column) => column.name === 'login_username')) {
|
|
console.log('Separating optional local login usernames from internal account identifiers.');
|
|
await database.exec('ALTER TABLE users ADD COLUMN login_username TEXT');
|
|
await database.exec('UPDATE users SET login_username=username WHERE password_hash IS NOT NULL');
|
|
}
|
|
}
|
|
|
|
// The project schema deliberately avoids semicolons inside string literals.
|
|
const statements = schema
|
|
.split(';')
|
|
.map((statement) => statement.trim())
|
|
.filter(Boolean);
|
|
|
|
for (const statement of statements) {
|
|
// This index is deferred until the additive legacy-column migration below.
|
|
if (statement.startsWith('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx')) continue;
|
|
if (statement.startsWith('CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx')) continue;
|
|
await database.exec(statement);
|
|
}
|
|
|
|
const loginEventsDefinition = await (await database.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='login_events'"
|
|
)).get();
|
|
if (typeof loginEventsDefinition?.sql === 'string' && !loginEventsDefinition.sql.includes('magic_link')) {
|
|
console.log('Expanding login event providers for passwordless authentication.');
|
|
await database.exec('ALTER TABLE login_events RENAME TO login_events_legacy');
|
|
await database.exec(`CREATE TABLE login_events (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
|
|
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
|
|
created_at TEXT NOT NULL
|
|
)`);
|
|
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at)
|
|
SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`);
|
|
await database.exec('DROP TABLE login_events_legacy');
|
|
await database.exec('CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)');
|
|
}
|
|
const loginEventColumns = await (await database.prepare("PRAGMA table_info('login_events')")).all();
|
|
for (const [name, definition] of [
|
|
['ip_address', 'TEXT'],
|
|
['user_agent', 'TEXT'],
|
|
['client_name', 'TEXT'],
|
|
['operating_system', 'TEXT'],
|
|
['device_type', 'TEXT'],
|
|
['location_country', 'TEXT'],
|
|
['location_region', 'TEXT'],
|
|
['location_city', 'TEXT'],
|
|
['failure_reason', 'TEXT'],
|
|
['risk_flags', 'TEXT'],
|
|
['credential_id', 'TEXT'],
|
|
['session_id', 'TEXT REFERENCES sessions(id) ON DELETE SET NULL'],
|
|
['session_ended_at', 'TEXT'],
|
|
['session_end_reason', 'TEXT'],
|
|
['sensitive_purged_at', 'TEXT']
|
|
]) {
|
|
if (!loginEventColumns.some((column) => column.name === name)) {
|
|
await database.exec(`ALTER TABLE login_events ADD COLUMN ${name} ${definition}`);
|
|
}
|
|
}
|
|
|
|
const proposalColumns = await (await database.prepare("PRAGMA table_info('data_change_proposals')")).all();
|
|
for (const [name, definition] of [
|
|
['draft_entity_id', 'TEXT'],
|
|
['applied_entity_id', 'TEXT']
|
|
]) {
|
|
if (!proposalColumns.some((column) => column.name === name)) {
|
|
await database.exec(`ALTER TABLE data_change_proposals ADD COLUMN ${name} ${definition}`);
|
|
}
|
|
}
|
|
await database.exec("UPDATE data_change_proposals SET draft_entity_id=id WHERE draft_entity_id IS NULL AND entity_id IS NULL");
|
|
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
|
|
|
|
const notificationColumns = await (await database.prepare("PRAGMA table_info('notifications')")).all();
|
|
if (!notificationColumns.some((column) => column.name === 'actions_json')) {
|
|
console.log('Adding structured action callouts to notifications.');
|
|
await database.exec('ALTER TABLE notifications ADD COLUMN actions_json TEXT');
|
|
}
|
|
|
|
const legacyProtocolEffects = await (await database.prepare(
|
|
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='os_protocol_attribute_effects'"
|
|
)).get();
|
|
if (legacyProtocolEffects) {
|
|
const legacyProtocolEffectCount = await (await database.prepare(
|
|
'SELECT COUNT(*) AS count FROM os_protocol_attribute_effects'
|
|
)).get();
|
|
if (Number(legacyProtocolEffectCount?.count ?? 0) > 0) {
|
|
throw new Error(
|
|
'Legacy OS Protocol attribute effects still contain rows. Export and reconcile them before removing the retired table.'
|
|
);
|
|
}
|
|
console.log('Removing the empty legacy OS Protocol attribute-effects table.');
|
|
await database.exec('DROP TABLE os_protocol_attribute_effects');
|
|
}
|
|
|
|
const proposalDefinition = await (await database.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
|
|
)).get();
|
|
if (typeof proposalDefinition?.sql === 'string' && !proposalDefinition.sql.includes("'configuration'")) {
|
|
console.log('Expanding moderated data modules for Global Configuration.');
|
|
await database.exec('PRAGMA foreign_keys=OFF');
|
|
await database.exec(`CREATE TABLE data_change_proposals_expanded (
|
|
id TEXT PRIMARY KEY,
|
|
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','protocols','skillchips')),
|
|
action TEXT NOT NULL,
|
|
entity_id TEXT,
|
|
draft_entity_id TEXT,
|
|
applied_entity_id TEXT,
|
|
summary TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
|
reviewed_by TEXT REFERENCES users(id),
|
|
review_note TEXT,
|
|
created_at TEXT NOT NULL,
|
|
reviewed_at TEXT,
|
|
applied_at TEXT
|
|
)`);
|
|
await database.exec(`INSERT INTO data_change_proposals_expanded
|
|
SELECT id,CASE module WHEN 'stats' THEN 'attributes' ELSE module END,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
|
|
FROM data_change_proposals`);
|
|
await database.exec('DROP TABLE data_change_proposals');
|
|
await database.exec('ALTER TABLE data_change_proposals_expanded RENAME TO data_change_proposals');
|
|
await database.exec('PRAGMA foreign_keys=ON');
|
|
}
|
|
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)');
|
|
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)');
|
|
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
|
|
|
|
const weaponModProposalDefinition = await (await database.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
|
|
)).get();
|
|
if (typeof weaponModProposalDefinition?.sql === 'string' && !weaponModProposalDefinition.sql.includes("'weaponmods'")) {
|
|
console.log('Adding Weapon Mods to moderated data modules.');
|
|
await database.exec('PRAGMA foreign_keys=OFF');
|
|
await database.exec(`CREATE TABLE data_change_proposals_weaponmods (
|
|
id TEXT PRIMARY KEY,
|
|
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','protocols','skillchips')),
|
|
action TEXT NOT NULL,
|
|
entity_id TEXT,
|
|
draft_entity_id TEXT,
|
|
applied_entity_id TEXT,
|
|
summary TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
|
reviewed_by TEXT REFERENCES users(id),
|
|
review_note TEXT,
|
|
created_at TEXT NOT NULL,
|
|
reviewed_at TEXT,
|
|
applied_at TEXT
|
|
)`);
|
|
await database.exec(`INSERT INTO data_change_proposals_weaponmods
|
|
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
|
|
FROM data_change_proposals`);
|
|
await database.exec('DROP TABLE data_change_proposals');
|
|
await database.exec('ALTER TABLE data_change_proposals_weaponmods RENAME TO data_change_proposals');
|
|
await database.exec('PRAGMA foreign_keys=ON');
|
|
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)');
|
|
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)');
|
|
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
|
|
}
|
|
|
|
const specializationProposalDefinition = await (await database.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
|
|
)).get();
|
|
if (typeof specializationProposalDefinition?.sql === 'string' && !specializationProposalDefinition.sql.includes("'specializations'")) {
|
|
console.log('Adding Specializations to moderated data modules.');
|
|
await database.exec('PRAGMA foreign_keys=OFF');
|
|
await database.exec(`CREATE TABLE data_change_proposals_specializations (
|
|
id TEXT PRIMARY KEY,
|
|
module TEXT NOT NULL CHECK(module IN ('configuration','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')),
|
|
action TEXT NOT NULL,
|
|
entity_id TEXT,
|
|
draft_entity_id TEXT,
|
|
applied_entity_id TEXT,
|
|
summary TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
|
reviewed_by TEXT REFERENCES users(id),
|
|
review_note TEXT,
|
|
created_at TEXT NOT NULL,
|
|
reviewed_at TEXT,
|
|
applied_at TEXT
|
|
)`);
|
|
await database.exec(`INSERT INTO data_change_proposals_specializations
|
|
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
|
|
FROM data_change_proposals`);
|
|
await database.exec('DROP TABLE data_change_proposals');
|
|
await database.exec('ALTER TABLE data_change_proposals_specializations RENAME TO data_change_proposals');
|
|
await database.exec('PRAGMA foreign_keys=ON');
|
|
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)');
|
|
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)');
|
|
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
|
|
}
|
|
|
|
const calculationProposalDefinition = await (await database.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
|
|
)).get();
|
|
if (typeof calculationProposalDefinition?.sql === 'string' && !calculationProposalDefinition.sql.includes("'calculations'")) {
|
|
console.log('Adding Combat Calculations to moderated data modules.');
|
|
await database.exec('PRAGMA foreign_keys=OFF');
|
|
await database.exec(`CREATE TABLE data_change_proposals_calculations (
|
|
id TEXT PRIMARY KEY,
|
|
module TEXT NOT NULL CHECK(module IN ('configuration','calculations','attributes','talents','brands','gear','weapons','weaponmods','gearmods','protocols','skillchips','specializations')),
|
|
action TEXT NOT NULL,
|
|
entity_id TEXT,
|
|
draft_entity_id TEXT,
|
|
applied_entity_id TEXT,
|
|
summary TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
|
|
proposed_by TEXT NOT NULL REFERENCES users(id),
|
|
reviewed_by TEXT REFERENCES users(id),
|
|
review_note TEXT,
|
|
created_at TEXT NOT NULL,
|
|
reviewed_at TEXT,
|
|
applied_at TEXT
|
|
)`);
|
|
await database.exec(`INSERT INTO data_change_proposals_calculations
|
|
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
|
|
FROM data_change_proposals`);
|
|
await database.exec('DROP TABLE data_change_proposals');
|
|
await database.exec('ALTER TABLE data_change_proposals_calculations RENAME TO data_change_proposals');
|
|
await database.exec('PRAGMA foreign_keys=ON');
|
|
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)');
|
|
await database.exec('CREATE INDEX IF NOT EXISTS data_change_proposals_module_status_idx ON data_change_proposals(module,status)');
|
|
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
|
|
}
|
|
|
|
const iconDefinition = await (await database.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
|
|
)).get();
|
|
if (typeof iconDefinition?.sql === 'string' && !iconDefinition.sql.includes("'skill_chip'")) {
|
|
console.log('Adding Skill Chip icon storage support.');
|
|
await database.exec(`CREATE TABLE game_data_icons_expanded (
|
|
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip')),
|
|
entity_id TEXT NOT NULL,
|
|
storage_key TEXT NOT NULL UNIQUE,
|
|
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY(entity_type, entity_id)
|
|
)`);
|
|
await database.exec(`INSERT INTO game_data_icons_expanded SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`);
|
|
await database.exec('DROP TABLE game_data_icons');
|
|
await database.exec('ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons');
|
|
}
|
|
|
|
const specializationIconDefinition = await (await database.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'"
|
|
)).get();
|
|
if (typeof specializationIconDefinition?.sql === 'string' && !specializationIconDefinition.sql.includes("'specialization_node'")) {
|
|
console.log('Adding Specialization icon storage support.');
|
|
await database.exec(`CREATE TABLE game_data_icons_specializations (
|
|
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
|
|
entity_id TEXT NOT NULL,
|
|
storage_key TEXT NOT NULL UNIQUE,
|
|
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY(entity_type, entity_id)
|
|
)`);
|
|
await database.exec(`INSERT INTO game_data_icons_specializations
|
|
SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`);
|
|
await database.exec('DROP TABLE game_data_icons');
|
|
await database.exec('ALTER TABLE game_data_icons_specializations RENAME TO game_data_icons');
|
|
}
|
|
|
|
const managedImageColumns = await (await database.prepare("PRAGMA table_info('managed_images')")).all();
|
|
for (const [name, definition] of [
|
|
['source_entity_type', 'TEXT'],
|
|
['source_entity_id', 'TEXT']
|
|
]) {
|
|
if (!managedImageColumns.some((column) => column.name === name)) {
|
|
console.log(`Linking Image Library assets to ISAC entities: ${name}.`);
|
|
await database.exec(`ALTER TABLE managed_images ADD COLUMN ${name} ${definition}`);
|
|
}
|
|
}
|
|
await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS managed_images_source_entity_idx
|
|
ON managed_images(source_entity_type,source_entity_id)
|
|
WHERE source_entity_type IS NOT NULL AND source_entity_id IS NOT NULL`);
|
|
|
|
const iconLibraryCreator = await (await database.prepare(`SELECT users.id
|
|
FROM users
|
|
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
|
LEFT JOIN user_roles ON user_roles.user_id=users.id
|
|
LEFT JOIN roles ON roles.id=user_roles.role_id
|
|
LEFT JOIN role_permissions ON role_permissions.role_id=roles.id
|
|
LEFT JOIN permissions ON permissions.id=role_permissions.permission_id
|
|
WHERE deleted_accounts.user_id IS NULL
|
|
GROUP BY users.id,users.created_at
|
|
ORDER BY MAX(CASE WHEN roles.name='Admin' THEN 1
|
|
WHEN permissions.name='*' THEN 1 ELSE 0 END) DESC,
|
|
users.created_at ASC LIMIT 1`)).get();
|
|
|
|
const iconLibraryCategory = {
|
|
talent: 'Talents',
|
|
brand_set: 'Brands',
|
|
os_protocol: 'OS Protocols',
|
|
skill_chip: 'Skill Chips',
|
|
specialization: 'Specializations',
|
|
specialization_ability: 'Specializations',
|
|
specialization_node: 'Specializations'
|
|
};
|
|
const iconLibraryLabel = {
|
|
talent: 'Talent',
|
|
brand_set: 'Brand Set',
|
|
os_protocol: 'OS Protocol',
|
|
skill_chip: 'Skill Chip',
|
|
specialization: 'Specialization',
|
|
specialization_ability: 'Specialization Ability',
|
|
specialization_node: 'Specialization Tree Node'
|
|
};
|
|
const normalizeLibraryTags = (values) => [...new Set(values
|
|
.flatMap((value) => String(value ?? '').split(','))
|
|
.map((value) => value.normalize('NFKC').trim().toLocaleLowerCase())
|
|
.filter(Boolean))].slice(0, 20);
|
|
const linkedIconMetadata = async (entityType, entityId) => {
|
|
switch (entityType) {
|
|
case 'talent': {
|
|
const row = await (await database.prepare(`SELECT talent.name,talent.category,scope.scope
|
|
FROM talents talent LEFT JOIN talent_weapon_scopes scope ON scope.talent_id=talent.id
|
|
WHERE talent.id=?`)).get(entityId);
|
|
return { name: row?.name, tags: [row?.category === 'Vest' ? 'Body Armor' : row?.category, row?.scope] };
|
|
}
|
|
case 'brand_set': {
|
|
const row = await (await database.prepare('SELECT name FROM brand_sets WHERE id=?')).get(entityId);
|
|
return { name: row?.name, tags: ['gear', 'set bonus'] };
|
|
}
|
|
case 'os_protocol': {
|
|
const row = await (await database.prepare('SELECT name,quality,primary_attribute_type FROM os_protocols WHERE id=?')).get(entityId);
|
|
return { name: row?.name, tags: [row?.quality, row?.primary_attribute_type] };
|
|
}
|
|
case 'skill_chip': {
|
|
const row = await (await database.prepare(`SELECT chip.name,ability.name AS ability_name,specialization.name AS specialization_name
|
|
FROM skill_chip_sets chip
|
|
LEFT JOIN specialization_abilities ability ON ability.id=chip.specialization_ability_id
|
|
LEFT JOIN specializations specialization ON specialization.id=ability.specialization_id
|
|
WHERE chip.id=?`)).get(entityId);
|
|
return { name: row?.name, tags: [row?.ability_name, row?.specialization_name] };
|
|
}
|
|
case 'specialization': {
|
|
const row = await (await database.prepare('SELECT name FROM specializations WHERE id=?')).get(entityId);
|
|
return { name: row?.name, tags: ['class'] };
|
|
}
|
|
case 'specialization_ability': {
|
|
const row = await (await database.prepare(`SELECT ability.name,specialization.name AS specialization_name
|
|
FROM specialization_abilities ability
|
|
JOIN specializations specialization ON specialization.id=ability.specialization_id
|
|
WHERE ability.id=?`)).get(entityId);
|
|
return { name: row?.name, tags: [row?.specialization_name, 'ability'] };
|
|
}
|
|
case 'specialization_node': {
|
|
const row = await (await database.prepare(`SELECT node.name,node.row_number,focus.name AS focus_name,
|
|
specialization.name AS specialization_name
|
|
FROM specialization_tree_nodes node
|
|
JOIN specialization_focuses focus ON focus.id=node.focus_id
|
|
JOIN specializations specialization ON specialization.id=focus.specialization_id
|
|
WHERE node.id=?`)).get(entityId);
|
|
return { name: row?.name, tags: [row?.specialization_name, row?.focus_name, `row ${row?.row_number}`, 'tree node'] };
|
|
}
|
|
default:
|
|
return { name: null, tags: [] };
|
|
}
|
|
};
|
|
const uniqueLibraryName = async (preferred, entityType, entityId) => {
|
|
const candidates = [
|
|
preferred,
|
|
`${preferred} · ${iconLibraryLabel[entityType] ?? 'ISAC Asset'}`,
|
|
`${preferred} · ${entityId.slice(0, 8)}`
|
|
];
|
|
for (const candidate of candidates) {
|
|
const normalized = candidate.slice(0, 80);
|
|
const conflict = await (await database.prepare('SELECT id FROM managed_images WHERE name=? COLLATE NOCASE')).get(normalized);
|
|
if (!conflict) return normalized;
|
|
}
|
|
return `${preferred.slice(0, 64)} · ${entityId.slice(0, 8)}`;
|
|
};
|
|
|
|
if (iconLibraryCreator?.id) {
|
|
const unregisteredIcons = await (await database.prepare(`SELECT icon.*
|
|
FROM game_data_icons icon
|
|
LEFT JOIN managed_images image
|
|
ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id
|
|
WHERE image.id IS NULL
|
|
ORDER BY icon.entity_type,icon.entity_id`)).all();
|
|
if (unregisteredIcons.length) console.log(`Registering ${unregisteredIcons.length} existing ISAC icons in the Image Library.`);
|
|
for (const icon of unregisteredIcons) {
|
|
const metadata = await linkedIconMetadata(String(icon.entity_type), String(icon.entity_id));
|
|
const label = iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset';
|
|
const preferredName = String(metadata.name ?? `${label} ${String(icon.entity_id).slice(0, 8)}`);
|
|
const name = await uniqueLibraryName(preferredName, String(icon.entity_type), String(icon.entity_id));
|
|
const tags = normalizeLibraryTags([
|
|
'isac', 'game data', String(icon.entity_type).replaceAll('_', ' '), preferredName, ...metadata.tags
|
|
]);
|
|
const now = String(icon.updated_at ?? new Date().toISOString());
|
|
await (await database.prepare(`INSERT INTO managed_images
|
|
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
|
|
source_entity_type,source_entity_id,created_by,created_at,updated_at)
|
|
VALUES(lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)
|
|
||'-'||substr('89ab',abs(random())%4+1,1)||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(6))),
|
|
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
|
name, iconLibraryCategory[icon.entity_type] ?? 'General', JSON.stringify(tags), 1, 0,
|
|
icon.storage_key, icon.storage_key, icon.byte_size, 256, 256,
|
|
icon.entity_type, icon.entity_id, iconLibraryCreator.id, now, now
|
|
);
|
|
}
|
|
const linkedIcons = await (await database.prepare(`SELECT icon.*,image.id AS library_image_id,image.tags_json
|
|
FROM game_data_icons icon
|
|
JOIN managed_images image
|
|
ON image.source_entity_type=icon.entity_type AND image.source_entity_id=icon.entity_id
|
|
ORDER BY icon.entity_type,icon.entity_id`)).all();
|
|
for (const icon of linkedIcons) {
|
|
const metadata = await linkedIconMetadata(String(icon.entity_type), String(icon.entity_id));
|
|
const preferredName = String(metadata.name ?? `${iconLibraryLabel[icon.entity_type] ?? 'ISAC Asset'} ${String(icon.entity_id).slice(0, 8)}`);
|
|
let existingTags = [];
|
|
try { existingTags = JSON.parse(String(icon.tags_json)); } catch { /* replace malformed legacy tags below */ }
|
|
existingTags = existingTags.filter((tag) => String(tag).toLocaleLowerCase() !== 'vest');
|
|
const tags = normalizeLibraryTags([
|
|
...existingTags, 'isac', 'game data', String(icon.entity_type).replaceAll('_', ' '), preferredName, ...metadata.tags
|
|
]);
|
|
await (await database.prepare(`UPDATE managed_images SET
|
|
category=?,tags_json=?,storage_key=?,original_filename=?,byte_size=?,width=256,height=256
|
|
WHERE id=?`)).run(
|
|
iconLibraryCategory[icon.entity_type] ?? 'General', JSON.stringify(tags),
|
|
icon.storage_key, icon.storage_key, icon.byte_size, icon.library_image_id
|
|
);
|
|
}
|
|
}
|
|
|
|
const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all();
|
|
if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) {
|
|
console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.');
|
|
await database.exec('ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_legacy');
|
|
await database.exec(`CREATE TABLE gear_tactical_augments (
|
|
id TEXT PRIMARY KEY,
|
|
gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')),
|
|
augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4),
|
|
effect_type TEXT NOT NULL CHECK(effect_type IN ('Custom', 'Base Percentage')),
|
|
stat_attribute_id TEXT REFERENCES stat_attributes(id),
|
|
value REAL NOT NULL,
|
|
unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(gear_slot, augment_slot),
|
|
CHECK((gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)),
|
|
CHECK((augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) OR (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0))
|
|
)`);
|
|
await database.exec(`INSERT INTO gear_tactical_augments
|
|
(id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,unlock_cost,created_at,updated_at)
|
|
SELECT id,gear_slot,augment_slot,
|
|
CASE WHEN augment_slot=1 THEN 'Custom' ELSE 'Base Percentage' END,
|
|
CASE WHEN augment_slot=1 THEN stat_attribute_id ELSE NULL END,
|
|
CASE
|
|
WHEN augment_slot=1 THEN value
|
|
WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=2 THEN 40
|
|
WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=3 THEN 70
|
|
WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=4 THEN 100
|
|
WHEN augment_slot=2 THEN 50
|
|
ELSE 100
|
|
END,
|
|
unlock_cost,created_at,updated_at
|
|
FROM gear_tactical_augments_legacy`);
|
|
await database.exec('DROP TABLE gear_tactical_augments_legacy');
|
|
}
|
|
|
|
const tieredGearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all();
|
|
if (tieredGearAugmentColumns.some((column) => column.name === 'unlock_cost')) {
|
|
console.log('Separating Gear Tactical Augment unlock costs by item tier.');
|
|
await database.exec('DROP TABLE IF EXISTS gear_tactical_augment_costs');
|
|
await database.exec('ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_single_cost');
|
|
await database.exec(`CREATE TABLE gear_tactical_augments (
|
|
id TEXT PRIMARY KEY,
|
|
gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')),
|
|
augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4),
|
|
effect_type TEXT NOT NULL CHECK(effect_type IN ('Custom', 'Base Percentage')),
|
|
stat_attribute_id TEXT REFERENCES stat_attributes(id),
|
|
value REAL NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(gear_slot, augment_slot),
|
|
CHECK((gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)),
|
|
CHECK((augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) OR (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0))
|
|
)`);
|
|
await database.exec(`INSERT INTO gear_tactical_augments
|
|
(id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at)
|
|
SELECT id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at
|
|
FROM gear_tactical_augments_single_cost`);
|
|
await database.exec(`CREATE TABLE gear_tactical_augment_costs (
|
|
augment_id TEXT NOT NULL REFERENCES gear_tactical_augments(id) ON DELETE CASCADE,
|
|
gear_tier INTEGER NOT NULL CHECK(gear_tier BETWEEN 0 AND 2),
|
|
unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0),
|
|
PRIMARY KEY(augment_id, gear_tier)
|
|
)`);
|
|
await database.exec(`INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost)
|
|
SELECT id,0,unlock_cost FROM gear_tactical_augments_single_cost
|
|
UNION ALL SELECT id,1,unlock_cost FROM gear_tactical_augments_single_cost
|
|
UNION ALL SELECT id,2,unlock_cost FROM gear_tactical_augments_single_cost`);
|
|
await database.exec('DROP TABLE gear_tactical_augments_single_cost');
|
|
}
|
|
|
|
const gearColumns = await (await database.prepare("PRAGMA table_info('gear')")).all();
|
|
if (gearColumns.some((column) => column.name === 'level')) {
|
|
console.log('Moving Gear level from canonical ISAC data to builder-instance state.');
|
|
await database.exec('ALTER TABLE gear DROP COLUMN level');
|
|
}
|
|
if (!gearColumns.some((column) => column.name === 'range_reference_level')) {
|
|
console.log('Recording the reference level for canonical Gear ranges.');
|
|
await database.exec('ALTER TABLE gear ADD COLUMN range_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(range_reference_level BETWEEN 1 AND 50)');
|
|
}
|
|
|
|
const weaponRangeColumns = await (await database.prepare("PRAGMA table_info('weapon_tier_ranges')")).all();
|
|
if (!weaponRangeColumns.some((column) => column.name === 'reference_level')) {
|
|
console.log('Recording the reference level for canonical Weapon ranges.');
|
|
await database.exec('ALTER TABLE weapon_tier_ranges ADD COLUMN reference_level INTEGER NOT NULL DEFAULT 50 CHECK(reference_level BETWEEN 1 AND 50)');
|
|
}
|
|
|
|
const attributeColumns = await (await database.prepare("PRAGMA table_info('stat_attributes')")).all();
|
|
for (const [name, definition] of [
|
|
['description', 'TEXT'],
|
|
['gear_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50)'],
|
|
['weapon_reference_level', 'INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50)']
|
|
]) {
|
|
if (!attributeColumns.some((column) => column.name === name)) {
|
|
console.log(name === 'description'
|
|
? 'Adding optional Attribute descriptions.'
|
|
: `Recording the reference level for Attribute source ranges: ${name}.`);
|
|
await database.exec(`ALTER TABLE stat_attributes ADD COLUMN ${name} ${definition}`);
|
|
}
|
|
}
|
|
|
|
const talentColumns = await (await database.prepare("PRAGMA table_info('talents')")).all();
|
|
for (const [name, definition] of [
|
|
['cooldown_seconds', 'REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds > 0)'],
|
|
['duration_seconds', 'REAL CHECK(duration_seconds IS NULL OR duration_seconds > 0)'],
|
|
['max_stacks', 'INTEGER CHECK(max_stacks IS NULL OR max_stacks > 0)']
|
|
]) {
|
|
if (!talentColumns.some((column) => column.name === name)) {
|
|
console.log(`Adding optional Talent mechanic: ${name}.`);
|
|
await database.exec(`ALTER TABLE talents ADD COLUMN ${name} ${definition}`);
|
|
}
|
|
}
|
|
|
|
const weaponModColumns = await (await database.prepare("PRAGMA table_info('weapon_mods')")).all();
|
|
if (weaponModColumns.some((column) => column.name === 'primary_attribute_type')) {
|
|
console.log('Converting Weapon Mods to builder-selected primary types and pooled secondary attributes.');
|
|
await database.exec('PRAGMA foreign_keys=OFF');
|
|
await database.exec(`INSERT OR IGNORE INTO weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id)
|
|
SELECT id,secondary_stat_1_id FROM weapon_mods
|
|
UNION SELECT id,secondary_stat_2_id FROM weapon_mods`);
|
|
await database.exec(`INSERT OR IGNORE INTO weapon_mod_secondary_quality_values(weapon_mod_id,stat_attribute_id,quality,min_value,max_value)
|
|
SELECT values_row.weapon_mod_id,mods.secondary_stat_1_id,values_row.quality,values_row.secondary_1_min,values_row.secondary_1_max
|
|
FROM weapon_mod_quality_values values_row JOIN weapon_mods mods ON mods.id=values_row.weapon_mod_id
|
|
UNION ALL
|
|
SELECT values_row.weapon_mod_id,mods.secondary_stat_2_id,values_row.quality,values_row.secondary_2_min,values_row.secondary_2_max
|
|
FROM weapon_mod_quality_values values_row JOIN weapon_mods mods ON mods.id=values_row.weapon_mod_id`);
|
|
await database.exec(`CREATE TABLE weapon_mod_quality_values_pooled (
|
|
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
|
|
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
|
|
primary_level_1_min REAL NOT NULL,
|
|
primary_level_1_max REAL NOT NULL,
|
|
primary_level_20_min REAL NOT NULL,
|
|
primary_level_20_max REAL NOT NULL,
|
|
PRIMARY KEY(weapon_mod_id,quality),
|
|
CHECK(primary_level_1_max>=primary_level_1_min),
|
|
CHECK(primary_level_20_max>=primary_level_20_min),
|
|
CHECK(primary_level_20_min>=primary_level_1_min),
|
|
CHECK(primary_level_20_max>=primary_level_1_max)
|
|
)`);
|
|
await database.exec(`INSERT INTO weapon_mod_quality_values_pooled
|
|
SELECT weapon_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max
|
|
FROM weapon_mod_quality_values`);
|
|
await database.exec('DROP TABLE weapon_mod_quality_values');
|
|
await database.exec('ALTER TABLE weapon_mod_quality_values_pooled RENAME TO weapon_mod_quality_values');
|
|
await database.exec(`CREATE TABLE weapon_mods_pooled (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
slot_type TEXT NOT NULL CHECK(slot_type IN ('Optics','Muzzle','Underbarrel','Magazine','Barrel','Upper Receiver','Lower Receiver','Trigger')),
|
|
exotic_weapon_id TEXT REFERENCES weapons(id),
|
|
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)`);
|
|
await database.exec(`INSERT INTO weapon_mods_pooled(id,name,slot_type,exotic_weapon_id,active,created_at,updated_at)
|
|
SELECT id,name,slot_type,exotic_weapon_id,active,created_at,updated_at FROM weapon_mods`);
|
|
await database.exec('DROP TABLE weapon_mods');
|
|
await database.exec('ALTER TABLE weapon_mods_pooled RENAME TO weapon_mods');
|
|
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS weapon_mods_name_nocase_idx ON weapon_mods(name COLLATE NOCASE)');
|
|
await database.exec('PRAGMA foreign_keys=ON');
|
|
}
|
|
|
|
const buildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all();
|
|
if (buildColumns.length && !buildColumns.some((column) => column.name === 'loadout_json')) {
|
|
console.log('Adding persistent builder loadout snapshots.');
|
|
await database.exec('ALTER TABLE builds ADD COLUMN loadout_json TEXT');
|
|
}
|
|
if (buildColumns.length) {
|
|
await database.exec("UPDATE builds SET visibility='private' WHERE visibility='draft'");
|
|
}
|
|
await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)');
|
|
|
|
const skillChipColumns = await (await database.prepare("PRAGMA table_info('skill_chip_sets')")).all();
|
|
if (skillChipColumns.length && !skillChipColumns.some((column) => column.name === 'specialization_ability_id')) {
|
|
console.log('Associating Skill Chip definitions with specialization abilities.');
|
|
await database.exec('ALTER TABLE skill_chip_sets ADD COLUMN specialization_ability_id TEXT REFERENCES specialization_abilities(id)');
|
|
}
|
|
await database.exec('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active)');
|
|
await database.exec(`UPDATE specialization_focuses SET builder_ready=0,updated_at='${new Date().toISOString()}'
|
|
WHERE builder_ready=1 AND (
|
|
SELECT COUNT(*) FROM specialization_focus_abilities link
|
|
JOIN specialization_abilities ability ON ability.id=link.ability_id AND ability.active=1
|
|
WHERE link.focus_id=specialization_focuses.id AND (
|
|
SELECT COUNT(*) FROM skill_chip_sets chips
|
|
WHERE chips.specialization_ability_id=ability.id AND chips.active=1
|
|
)=3
|
|
)<>3`);
|
|
|
|
console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
|
|
|
|
if (!databaseAlreadyExisted && process.env.SKIP_DEFAULT_USERS !== 'true') {
|
|
console.log('New database detected; creating default users.');
|
|
await initializeDefaultUsers(database);
|
|
} else if (!databaseAlreadyExisted) {
|
|
console.log('New database detected; default-user seeding is disabled for this environment.');
|
|
}
|