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 gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value); const hasExactKeys = (value, keys) => isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value); const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value); const isRoll = (value) => hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value); const isRollPair = (value) => Array.isArray(value) && value.length === 2 && value.every(isRoll); const isGearSelection = (value) => hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots']) && ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string') && ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key])) && isRollPair(value.rolls) && Array.isArray(value.augmentSlots) && value.augmentSlots.every(isFiniteNumber); const isWeaponSelection = (value) => hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls']) && ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string') && ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key])) && isRollPair(value.rolls); const isChipSelection = (value) => hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) && typeof value.setId === 'string' && (value.quality === 'Superior' || value.quality === 'High-end') && isRoll(value.primary) && isRollPair(value.secondary); const isProtocolSelection = (value) => hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) && typeof value.itemId === 'string' && isFiniteNumber(value.level) && isFiniteNumber(value.talentUpgrades); const combatProfileKeys = ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity']; const isCombatProfile = (value) => hasExactKeys(value, combatProfileKeys) && combatProfileKeys.every((key) => isFiniteNumber(value[key])); const isEffectStates = (value) => isRecord(value) && Object.values(value).every((entry) => { if (!hasExactKeys(entry, entry?.overrides === undefined ? ['mode'] : ['mode', 'overrides']) || !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false; return entry.overrides === undefined || ( isRecord(entry.overrides) && Object.entries(entry.overrides).every(([key, override]) => combatProfileKeys.includes(key) && isFiniteNumber(override) ) ); }); const isCurrentLoadoutSnapshot = (raw) => { let value; try { value = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } return hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization']) && value.version === 2 && typeof value.buildName === 'string' && hasExactKeys(value.gear, gearSlots) && gearSlots.every((slot) => isGearSelection(value.gear[slot])) && Array.isArray(value.weapons) && value.weapons.length === 2 && value.weapons.every(isWeaponSelection) && Array.isArray(value.chips) && value.chips.length === 9 && value.chips.every(isChipSelection) && isProtocolSelection(value.protocol) && Array.isArray(value.activeTalents) && value.activeTalents.every((talent) => typeof talent === 'string') && hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile']) && typeof value.specialization.focusId === 'string' && isRecord(value.specialization.nodeRanks) && Object.values(value.specialization.nodeRanks).every(isFiniteNumber) && isEffectStates(value.specialization.effectStates) && isCombatProfile(value.specialization.combatProfile); }; 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) { console.log('Removing the retired 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 nameOnlyAttributeIndex = await (await database.prepare( "SELECT 1 AS present FROM sqlite_master WHERE type='index' AND name='stat_attributes_name_nocase_idx'" )).get(); if (nameOnlyAttributeIndex) { console.log('Allowing Attribute names to repeat when their units differ.'); await database.exec('DROP INDEX stat_attributes_name_nocase_idx'); } for (const [previousName, currentName] of [ ['Engineering %', 'Engineering'], ['Firepower %', 'Firepower'], ['Toughness %', 'Toughness'], ['Weapon Damage %', 'Weapon Damage'] ]) { const previous = await (await database.prepare( 'SELECT id FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?' )).get(previousName, '%'); const current = await (await database.prepare( 'SELECT id FROM stat_attributes WHERE name=? COLLATE NOCASE AND unit=?' )).get(currentName, '%'); if (previous && !current) { await (await database.prepare('UPDATE stat_attributes SET name=?,updated_at=? WHERE id=?')) .run(currentName, new Date().toISOString(), previous.id); } } await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)'); 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'"); const builds = await (await database.prepare('SELECT id,loadout_json FROM builds')).all(); const obsoleteBuildIds = builds .filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json)) .map((build) => String(build.id)); if (obsoleteBuildIds.length) { console.log(`Removing ${obsoleteBuildIds.length} build record(s) that do not use the current loadout format.`); await database.exec('BEGIN'); try { for (const id of obsoleteBuildIds) { await (await database.prepare('UPDATE builds SET source_build_id=NULL WHERE source_build_id=?')).run(id); await (await database.prepare('DELETE FROM builds WHERE id=?')).run(id); } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } } const currentBuildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all(); const loadoutColumn = currentBuildColumns.find((column) => column.name === 'loadout_json'); if (loadoutColumn && Number(loadoutColumn.notnull) !== 1) { console.log('Requiring every build record to contain a current loadout snapshot.'); await database.exec('DROP INDEX IF EXISTS builds_visibility_updated_idx'); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec('DROP TABLE IF EXISTS builds_current'); await database.exec(`CREATE TABLE builds_current ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds_current(id), loadout_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`); await database.exec(`INSERT INTO builds_current(id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at) SELECT id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at FROM builds`); await database.exec('DROP TABLE builds'); await database.exec('ALTER TABLE builds_current RENAME TO builds'); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } } } 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)'); } const currentSkillChipColumns = await (await database.prepare("PRAGMA table_info('skill_chip_sets')")).all(); const skillChipAbilityColumn = currentSkillChipColumns.find((column) => column.name === 'specialization_ability_id'); if (skillChipAbilityColumn && Number(skillChipAbilityColumn.notnull) !== 1) { const unassignedSkillChips = await (await database.prepare( 'SELECT id FROM skill_chip_sets WHERE specialization_ability_id IS NULL' )).all(); if (unassignedSkillChips.length) { console.log(`Removing ${unassignedSkillChips.length} Skill Chip record(s) without a specialization ability.`); for (const row of unassignedSkillChips) { await (await database.prepare('DELETE FROM skill_chip_sets WHERE id=?')).run(row.id); } } console.log('Requiring every Skill Chip definition to reference a specialization ability.'); await database.exec('DROP INDEX IF EXISTS skill_chip_sets_ability_idx'); await database.exec('DROP INDEX IF EXISTS skill_chip_sets_name_nocase_idx'); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec('DROP TABLE IF EXISTS skill_chip_sets_current'); await database.exec(`CREATE TABLE skill_chip_sets_current ( id TEXT PRIMARY KEY, name TEXT NOT NULL, specialization_ability_id TEXT NOT NULL REFERENCES specialization_abilities(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 skill_chip_sets_current(id,name,specialization_ability_id,active,created_at,updated_at) SELECT id,name,specialization_ability_id,active,created_at,updated_at FROM skill_chip_sets`); await database.exec('DROP TABLE skill_chip_sets'); await database.exec('ALTER TABLE skill_chip_sets_current RENAME TO skill_chip_sets'); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } } await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE)'); await database.exec('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active)'); const calibrationCases = await (await database.prepare( 'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases' )).all(); const obsoleteCalibrationIds = calibrationCases .filter((calibrationCase) => !isCurrentLoadoutSnapshot(calibrationCase.loadout_snapshot_json)) .map((calibrationCase) => String(calibrationCase.id)); if (obsoleteCalibrationIds.length) { console.log(`Removing ${obsoleteCalibrationIds.length} calculation calibration case(s) with obsolete loadout snapshots.`); for (const id of obsoleteCalibrationIds) { await (await database.prepare('DELETE FROM calculation_calibration_cases WHERE id=?')).run(id); } } const foreignKeyFailures = await (await database.prepare('PRAGMA foreign_key_check')).all(); if (foreignKeyFailures.length) { throw new Error(`Schema migration left ${foreignKeyFailures.length} foreign-key violation(s).`); } 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.'); }