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 { randomUUID } from 'node:crypto'; 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 displayNameKey = (displayName) => String(displayName ?? '') .normalize('NFKC') .toLocaleLowerCase('en-US'); const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const publicProfileSlugBase = (displayName, userId) => { const normalized = String(displayName ?? '') .normalize('NFKC') .toLocaleLowerCase('en-US') .replace(/[^\p{L}\p{N}\p{M}]+/gu, '-') .replace(/^-+|-+$/g, '') .slice(0, 64) .replace(/-+$/g, ''); const fallback = `agent-${String(userId).replaceAll('-', '').slice(0, 8).toLowerCase()}`; if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback; return uuidPattern.test(normalized) ? `agent-${normalized}` : normalized; }; 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' && ['Standard', 'Specialized', 'Superior', 'High-end'].includes(value.quality) && isRoll(value.primary) && Array.isArray(value.secondary) && value.secondary.length === ({ Standard: 0, Specialized: 1, Superior: 2, 'High-end': 2 }[ value.quality ] ?? -1) && value.secondary.every(isRoll); const isProtocolSelection = (value) => hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) && typeof value.itemId === 'string' && isFiniteNumber(value.level) && isFiniteNumber(value.talentUpgrades); const numericCombatProfileKeys = [ 'encounterSeconds', 'hitRate', 'headshotRate', 'shotsPerBurst', 'burstPauseSeconds', 'sameTargetSeconds', 'distinctTargetsPerMinute', 'killsPerMinute', 'headshotKillRate', 'damageTakenPerMinute', 'swapsPerMinute', 'targetDistanceMeters', 'healthPercent', ]; const combatProfileKeys = [...numericCombatProfileKeys, 'extraHealthActive']; const isCombatProfile = (value) => hasExactKeys(value, combatProfileKeys) && numericCombatProfileKeys.every((key) => isFiniteNumber(value[key])) && typeof value.extraHealthActive === 'boolean'; const isEffectStates = (value) => isRecord(value) && Object.values(value).every((entry) => { if ( !isRecord(entry) || Object.keys(entry).some( (key) => !['mode', 'overrides', 'inputs'].includes(key), ) || !['off', 'peak', 'encounter'].includes(String(entry.mode)) ) return false; if ( entry.inputs !== undefined && (!isRecord(entry.inputs) || Object.values(entry.inputs).some( (input) => typeof input !== 'boolean' && !isFiniteNumber(input), )) ) return false; return ( entry.overrides === undefined || (isRecord(entry.overrides) && Object.entries(entry.overrides).every(([key, override]) => key === 'extraHealthActive' ? typeof override === 'boolean' : numericCombatProfileKeys.includes(key) && isFiniteNumber(override), )) ); }); const isSkillActivations = (value) => isRecord(value) && Object.values(value).every( (entry) => isRecord(entry) && Object.keys(entry).every((key) => ['mode', 'usesPerMinute', 'targets'].includes(key), ) && ['off', 'on_cooldown', 'custom_per_minute'].includes( String(entry.mode), ) && isFiniteNumber(entry.targets) && (entry.usesPerMinute === undefined || isFiniteNumber(entry.usesPerMinute)), ); 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', 'conditionalEffects', 'combatProfile', 'calculationMode', 'skillActivations', 'specialization', ]) && value.version === 5 && typeof value.buildName === 'string' && ['maximum', 'custom'].includes(value.calculationMode) && 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) && isEffectStates(value.conditionalEffects) && isCombatProfile(value.combatProfile) && isSkillActivations(value.skillActivations) && hasExactKeys(value.specialization, ['focusId', 'nodeRanks']) && typeof value.specialization.focusId === 'string' && isRecord(value.specialization.nodeRanks) && Object.values(value.specialization.nodeRanks).every(isFiniteNumber) ); }; 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', ); } } const userProfilesTable = await ( await database.prepare( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='user_profiles'", ) ).get(); if (userProfilesTable) { const profileColumns = await ( await database.prepare("PRAGMA table_info('user_profiles')") ).all(); if (!profileColumns.some((column) => column.name === 'bio_markdown')) { console.log('Adding Markdown biographies to public agent profiles.'); await database.exec( "ALTER TABLE user_profiles ADD COLUMN bio_markdown TEXT NOT NULL DEFAULT ''", ); } if (!profileColumns.some((column) => column.name === 'is_public')) { console.log('Adding public profile visibility controls.'); await database.exec( 'ALTER TABLE user_profiles ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1))', ); } if (!profileColumns.some((column) => column.name === 'bio_updated_at')) { await database.exec( 'ALTER TABLE user_profiles ADD COLUMN bio_updated_at TEXT', ); } if (!profileColumns.some((column) => column.name === 'public_slug')) { console.log('Adding readable public profile slugs.'); await database.exec( 'ALTER TABLE user_profiles ADD COLUMN public_slug TEXT', ); } if (!profileColumns.some((column) => column.name === 'display_name_key')) { console.log('Adding normalized display-name identity keys.'); await database.exec( 'ALTER TABLE user_profiles ADD COLUMN display_name_key TEXT', ); } } const commentsDefinition = await ( await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='comments'", ) ).get(); if ( typeof commentsDefinition?.sql === 'string' && commentsDefinition.sql.includes('build_id') ) { console.log( 'Normalizing build comments into reusable community comment threads.', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec('ALTER TABLE comments RENAME TO comments_legacy'); await database.exec(`CREATE TABLE comment_threads ( id TEXT PRIMARY KEY, target_type TEXT NOT NULL CHECK(target_type IN ('build', 'guide', 'profile')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(target_type, target_id) )`); await database.exec(`CREATE TABLE comments ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE, author_id TEXT NOT NULL REFERENCES users(id), parent_id TEXT REFERENCES comments(id), body_markdown TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending_review' CHECK(status IN ('pending_review', 'visible', 'removed')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, removed_at TEXT, removed_by TEXT REFERENCES users(id), removal_reason TEXT )`); await database.exec(`INSERT INTO comment_threads(id,target_type,target_id,created_at) SELECT 'build:' || build_id,'build',build_id,MIN(created_at) FROM comments_legacy GROUP BY build_id`); await database.exec(`INSERT INTO comments( id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at ) SELECT id,'build:' || build_id,user_id,NULL,body,'visible',created_at,created_at FROM comments_legacy`); await database.exec('DROP TABLE comments_legacy'); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } } const normalizedCommentsDefinition = await ( await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='comments'", ) ).get(); if ( typeof normalizedCommentsDefinition?.sql === 'string' && !normalizedCommentsDefinition.sql.includes("'pending_review'") ) { console.log('Adding pre-publication review status to community comments.'); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec('ALTER TABLE comments RENAME TO comments_previous'); await database.exec(`CREATE TABLE comments ( id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE, author_id TEXT NOT NULL REFERENCES users(id), parent_id TEXT REFERENCES comments(id), body_markdown TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending_review' CHECK(status IN ('pending_review', 'visible', 'removed')), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, removed_at TEXT, removed_by TEXT REFERENCES users(id), removal_reason TEXT )`); await database.exec(`INSERT INTO comments( id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at, removed_at,removed_by,removal_reason ) SELECT id,thread_id,author_id,parent_id,body_markdown,status,created_at,updated_at, removed_at,removed_by,removal_reason FROM comments_previous`); await database.exec('DROP TABLE comments_previous'); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } } const commentThreadsDefinition = await ( await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='comment_threads'", ) ).get(); if ( typeof commentThreadsDefinition?.sql === 'string' && (!commentThreadsDefinition.sql.includes("'game_data'") || !commentThreadsDefinition.sql.includes("'news'") || !commentThreadsDefinition.sql.includes('pinned_comment_id')) ) { console.log( 'Adding game-data and News discussion targets with pinned community notes.', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec(`CREATE TABLE comment_threads_expanded ( id TEXT PRIMARY KEY, target_type TEXT NOT NULL CHECK(target_type IN ('build', 'guide', 'news', 'profile', 'game_data')), target_id TEXT NOT NULL, pinned_comment_id TEXT REFERENCES comments(id) ON DELETE SET NULL, created_at TEXT NOT NULL, UNIQUE(target_type, target_id) )`); const pinnedCommentProjection = commentThreadsDefinition.sql.includes( 'pinned_comment_id', ) ? 'pinned_comment_id' : 'NULL'; await database.exec(`INSERT INTO comment_threads_expanded(id,target_type,target_id,pinned_comment_id,created_at) SELECT id,target_type,target_id,${pinnedCommentProjection},created_at FROM comment_threads`); await database.exec('DROP TABLE comment_threads'); await database.exec( 'ALTER TABLE comment_threads_expanded RENAME TO comment_threads', ); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } } const moderationScansDefinition = await ( await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='content_moderation_scans'", ) ).get(); const moderationScansMissingEvidence = typeof moderationScansDefinition?.sql === 'string' && (!moderationScansDefinition.sql.includes('author_id TEXT') || !moderationScansDefinition.sql.includes('risk_level TEXT') || !moderationScansDefinition.sql.includes('reason TEXT')); if ( typeof moderationScansDefinition?.sql === 'string' && (!moderationScansDefinition.sql.includes("'avatar'") || moderationScansMissingEvidence) ) { if (moderationScansMissingEvidence) { const existingScans = await ( await database.prepare( 'SELECT COUNT(*) AS count FROM content_moderation_scans', ) ).get(); if (Number(existingScans?.count) > 0) { throw new Error( 'Existing moderation scans lack required author, risk, or reason evidence and cannot be backfilled safely.', ); } } console.log('Aligning automated community moderation scan evidence.'); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec(`CREATE TABLE content_moderation_scans_expanded ( id TEXT PRIMARY KEY, content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')), content_id TEXT NOT NULL, author_id TEXT NOT NULL REFERENCES users(id), content_hash TEXT NOT NULL CHECK(length(content_hash)>0), revision_id TEXT REFERENCES moderated_content_revisions(id), policy_version INTEGER NOT NULL CHECK(policy_version>=1), provider TEXT NOT NULL CHECK(length(trim(provider))>0), model TEXT NOT NULL CHECK(length(trim(model))>0), provider_request_id TEXT, status TEXT NOT NULL CHECK(status IN ('approved','review','priority_review','error')), risk_level TEXT NOT NULL CHECK(risk_level IN ('low','medium','high')), reason TEXT NOT NULL CHECK(length(trim(reason))>0), scores_json TEXT NOT NULL DEFAULT '{}', flags_json TEXT NOT NULL DEFAULT '[]', rule_matches_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, completed_at TEXT, error_code TEXT, error_message TEXT, UNIQUE(content_type,content_id,content_hash,policy_version,provider,model), CHECK( (content_type IN ('comment','avatar') AND revision_id IS NULL) OR (content_type IN ('profile_bio','guide') AND revision_id IS NOT NULL) ), CHECK( (status='error' AND error_code IS NOT NULL) OR (status<>'error' AND error_code IS NULL AND error_message IS NULL) ) )`); if (!moderationScansMissingEvidence) { await database.exec(`INSERT INTO content_moderation_scans_expanded( id,content_type,content_id,author_id,content_hash,revision_id,policy_version,provider,model, provider_request_id,status,risk_level,reason,scores_json,flags_json,rule_matches_json, created_at,completed_at,error_code,error_message ) SELECT id,content_type,content_id,author_id,content_hash,revision_id,policy_version,provider,model, provider_request_id,status,risk_level,reason,scores_json,flags_json,rule_matches_json, created_at,completed_at,error_code,error_message FROM content_moderation_scans`); } await database.exec('DROP TABLE content_moderation_scans'); await database.exec( 'ALTER TABLE content_moderation_scans_expanded RENAME TO content_moderation_scans', ); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } } const communityModerationSettingsColumns = await ( await database.prepare("PRAGMA table_info('community_moderation_settings')") ).all(); if ( communityModerationSettingsColumns.length && !communityModerationSettingsColumns.some( (column) => column.name === 'scan_avatars', ) ) { console.log('Adding avatar scanning to community moderation settings.'); await database.exec(`ALTER TABLE community_moderation_settings ADD COLUMN scan_avatars INTEGER NOT NULL DEFAULT 1 CHECK(scan_avatars IN (0,1))`); } const avatarReviewColumns = await ( await database.prepare("PRAGMA table_info('avatar_reviews')") ).all(); if ( avatarReviewColumns.length && !avatarReviewColumns.some((column) => column.name === 'review_origin') ) { console.log('Adding review origin evidence to avatar oversight.'); await database.exec(`ALTER TABLE avatar_reviews ADD COLUMN review_origin TEXT NOT NULL DEFAULT 'legacy' CHECK(review_origin IN ('automated','user_report','legacy'))`); } const contributorProposalColumns = await ( await database.prepare("PRAGMA table_info('data_change_proposals')") ).all(); if ( contributorProposalColumns.length && !contributorProposalColumns.some( (column) => column.name === 'contributor_note', ) ) { console.log( 'Adding optional contributor rationale to data change proposals.', ); await database.exec( 'ALTER TABLE data_change_proposals ADD COLUMN contributor_note TEXT', ); } const legacyBuildColumns = await ( await database.prepare("PRAGMA table_info('builds')") ).all(); if ( legacyBuildColumns.some( (column) => column.name === 'summary' || column.name === 'guide_body', ) ) { console.log( 'Removing the retired Build Notes prototype without migrating its development content.', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec(`DELETE FROM comments WHERE thread_id IN ( SELECT id FROM comment_threads WHERE target_type='guide' )`); await database.exec( "DELETE FROM comment_threads WHERE target_type='guide'", ); await database.exec(`DELETE FROM content_moderation_reviews WHERE scan_id IN ( SELECT id FROM content_moderation_scans WHERE content_type='guide' )`); await database.exec( "DELETE FROM content_moderation_scans WHERE content_type='guide'", ); await database.exec( "DELETE FROM moderated_content_revisions WHERE content_type='guide'", ); await database.exec( "DELETE FROM user_content_reports WHERE content_type='guide'", ); await database.exec('DROP TABLE IF EXISTS builds_without_notes'); await database.exec(`CREATE TABLE builds_without_notes ( 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' CHECK(visibility IN ('private','unlisted','public')), source_build_id TEXT REFERENCES builds_without_notes(id), loadout_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`); await database.exec(`INSERT INTO builds_without_notes( id,owner_id,title,slug,visibility,source_build_id,loadout_json,created_at,updated_at ) SELECT id,owner_id,title,slug, CASE WHEN visibility IN ('private','unlisted','public') THEN visibility ELSE 'private' END, source_build_id,loadout_json,created_at,updated_at FROM builds`); await database.exec('DROP TABLE builds'); await database.exec('ALTER TABLE builds_without_notes RENAME TO builds'); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } } // 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; if ( statement.startsWith( 'CREATE INDEX IF NOT EXISTS conditional_effect_clauses_event_idx', ) ) continue; if ( statement.startsWith( 'CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx', ) ) continue; if ( statement.startsWith( 'CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx', ) ) continue; if ( statement.startsWith( 'CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_guide_unique_idx', ) ) continue; if ( statement.startsWith( 'CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_anonymous_pending_idx', ) ) continue; await database.exec(statement); } const articleRevisionColumns = await ( await database.prepare("PRAGMA table_info('article_revisions')") ).all(); if (articleRevisionColumns.some((column) => column.name === 'plaint_text')) { console.log('Correcting the article revision plain-text column name.'); await database.exec( 'ALTER TABLE article_revisions RENAME COLUMN plaint_text TO plain_text', ); } const externalNewsSourceColumns = await ( await database.prepare("PRAGMA table_info('external_news_sources')") ).all(); if ( externalNewsSourceColumns.length && !externalNewsSourceColumns.some((column) => column.name === 'thumbnail_url') ) { console.log('Adding Ubisoft News thumbnail metadata.'); await database.exec( 'ALTER TABLE external_news_sources ADD COLUMN thumbnail_url TEXT', ); } if ( externalNewsSourceColumns.length && !externalNewsSourceColumns.some((column) => column.name === 'thumbnail_alt') ) { await database.exec( "ALTER TABLE external_news_sources ADD COLUMN thumbnail_alt TEXT NOT NULL DEFAULT ''", ); } if (externalNewsSourceColumns.length) { const missingThumbnail = await ( await database.prepare( 'SELECT 1 AS present FROM external_news_sources WHERE thumbnail_url IS NULL LIMIT 1', ) ).get(); if (missingThumbnail) { await ( await database.prepare( "UPDATE external_news_sync_state SET next_check_at='1970-01-01T00:00:00.000Z' WHERE provider='ubisoft'", ) ).run(); } } const currentModerationScanColumns = await ( await database.prepare("PRAGMA table_info('content_moderation_scans')") ).all(); if ( currentModerationScanColumns.length && !currentModerationScanColumns.some( (column) => column.name === 'article_revision_id', ) ) { console.log( 'Binding Guide moderation evidence to immutable article revisions.', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec(`CREATE TABLE content_moderation_scans_article ( id TEXT PRIMARY KEY, content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')), content_id TEXT NOT NULL, author_id TEXT NOT NULL REFERENCES users(id), content_hash TEXT NOT NULL CHECK(length(content_hash)>0), revision_id TEXT REFERENCES moderated_content_revisions(id), article_revision_id TEXT REFERENCES article_revisions(id), policy_version INTEGER NOT NULL CHECK(policy_version>=1), provider TEXT NOT NULL CHECK(length(trim(provider))>0), model TEXT NOT NULL CHECK(length(trim(model))>0), provider_request_id TEXT, status TEXT NOT NULL CHECK(status IN ('approved','review','priority_review','error')), risk_level TEXT NOT NULL CHECK(risk_level IN ('low','medium','high')), reason TEXT NOT NULL CHECK(length(trim(reason))>0), scores_json TEXT NOT NULL DEFAULT '{}', flags_json TEXT NOT NULL DEFAULT '[]', rule_matches_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, completed_at TEXT, error_code TEXT, error_message TEXT, CHECK( (content_type IN ('comment','avatar') AND revision_id IS NULL AND article_revision_id IS NULL) OR (content_type='profile_bio' AND revision_id IS NOT NULL AND article_revision_id IS NULL) OR (content_type='guide' AND revision_id IS NULL AND article_revision_id IS NOT NULL) ), CHECK( (status='error' AND error_code IS NOT NULL) OR (status<>'error' AND error_code IS NULL AND error_message IS NULL) ) )`); await database.exec(`INSERT INTO content_moderation_scans_article( id,content_type,content_id,author_id,content_hash,revision_id,article_revision_id, policy_version,provider,model,provider_request_id,status,risk_level,reason, scores_json,flags_json,rule_matches_json,created_at,completed_at,error_code,error_message ) SELECT id,content_type,content_id,author_id,content_hash,revision_id,NULL, policy_version,provider,model,provider_request_id,status,risk_level,reason, scores_json,flags_json,rule_matches_json,created_at,completed_at,error_code,error_message FROM content_moderation_scans WHERE content_type<>'guide'`); await database.exec('DROP TABLE content_moderation_scans'); await database.exec( 'ALTER TABLE content_moderation_scans_article RENAME TO content_moderation_scans', ); 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 content_moderation_scans_pending_queue_idx ON content_moderation_scans(status,created_at) WHERE status IN ('review','priority_review')`); await database.exec(`CREATE INDEX IF NOT EXISTS content_moderation_scans_content_idx ON content_moderation_scans(content_type,content_id,created_at DESC)`); await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_direct_unique_idx ON content_moderation_scans(content_type,content_id,content_hash,policy_version,provider,model) WHERE content_type IN ('comment','avatar')`); await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_profile_unique_idx ON content_moderation_scans(revision_id,policy_version,provider,model) WHERE content_type='profile_bio'`); await database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_scans_guide_unique_idx ON content_moderation_scans(article_revision_id,policy_version,provider,model) WHERE content_type='guide'`); } const reportColumns = await ( await database.prepare("PRAGMA table_info('user_content_reports')") ).all(); if ( reportColumns.length && !reportColumns.some((column) => column.name === 'anonymous_token_hash') ) { console.log('Adding revision-bound anonymous Guide reports.'); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec(`CREATE TABLE user_content_reports_article ( id TEXT PRIMARY KEY, content_type TEXT NOT NULL CHECK(content_type IN ('comment','profile_bio','guide','avatar')), content_id TEXT NOT NULL, reporter_id TEXT REFERENCES users(id), anonymous_token_hash TEXT REFERENCES anonymous_visitors(token_hash), article_revision_id TEXT REFERENCES article_revisions(id), category TEXT NOT NULL CHECK(category IN ('spam','harassment','hate','sexual','violence','self_harm','privacy','other')), details TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','resolved','dismissed')), created_at TEXT NOT NULL, reviewed_at TEXT, reviewed_by TEXT REFERENCES users(id), resolution_reason TEXT, CHECK((reporter_id IS NOT NULL AND anonymous_token_hash IS NULL) OR (reporter_id IS NULL AND anonymous_token_hash IS NOT NULL)), CHECK((content_type='guide' AND article_revision_id IS NOT NULL) OR (content_type<>'guide' AND article_revision_id IS NULL)), CHECK( (status='pending' AND reviewed_at IS NULL AND reviewed_by IS NULL AND resolution_reason IS NULL) OR (status IN ('resolved','dismissed') AND reviewed_at IS NOT NULL AND reviewed_by IS NOT NULL AND resolution_reason IS NOT NULL AND length(trim(resolution_reason))>0) ) )`); await database.exec(`INSERT INTO user_content_reports_article( id,content_type,content_id,reporter_id,anonymous_token_hash,article_revision_id, category,details,status,created_at,reviewed_at,reviewed_by,resolution_reason ) SELECT id,content_type,content_id,reporter_id,NULL,NULL,category,details,status,created_at, reviewed_at,reviewed_by,resolution_reason FROM user_content_reports WHERE content_type<>'guide'`); await database.exec('DROP TABLE user_content_reports'); await database.exec( 'ALTER TABLE user_content_reports_article RENAME TO user_content_reports', ); 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 user_content_reports_pending_queue_idx ON user_content_reports(created_at) WHERE status='pending'", ); await database.exec( 'CREATE INDEX IF NOT EXISTS user_content_reports_content_idx ON user_content_reports(content_type,content_id,created_at DESC)', ); await database.exec( "CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_pending_idx ON user_content_reports(reporter_id,content_type,content_id) WHERE status='pending' AND reporter_id IS NOT NULL", ); await database.exec( "CREATE UNIQUE INDEX IF NOT EXISTS user_content_reports_one_anonymous_pending_idx ON user_content_reports(anonymous_token_hash,content_type,content_id) WHERE status='pending' AND anonymous_token_hash IS NOT NULL", ); } await database.exec( 'CREATE UNIQUE INDEX IF NOT EXISTS content_moderation_reviews_scan_unique_idx ON content_moderation_reviews(scan_id)', ); const weaponBonusAttributes = await ( await database.prepare(`SELECT id,name FROM stat_attributes WHERE name IN ('Weapon Critical Hit Chance','Weapon Critical Hit Damage','Headshot Damage')`) ).all(); if (weaponBonusAttributes.length && weaponBonusAttributes.length !== 3) { throw new Error( 'The shared weapon-category bonus migration requires all three fixed weapon Attributes.', ); } if (weaponBonusAttributes.length === 3) { const attributeId = (name) => String( weaponBonusAttributes.find((attribute) => attribute.name === name)?.id ?? '', ); const defaults = [ ['Weapon Critical Hit Chance', 5], ['Weapon Critical Hit Damage', 50], ['Headshot Damage', 40], ]; await database.exec('BEGIN'); try { for (const category of ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']) { for (let index = 0; index < defaults.length; index += 1) { const [name, defaultValue] = defaults[index]; const value = category === 'MMR' && name === 'Headshot Damage' ? 70 : defaultValue; await ( await database.prepare(`INSERT OR IGNORE INTO weapon_category_attribute_bonuses( id,weapon_category,stat_attribute_id,value,sort_order,updated_at ) VALUES(?,?,?,?,?,?)`) ).run( randomUUID(), category, attributeId(name), value, index + 1, new Date().toISOString(), ); } } const configured = await ( await database.prepare( 'SELECT COUNT(*) AS count FROM weapon_category_attribute_bonuses', ) ).get(); if (Number(configured?.count) !== 18) { throw new Error( 'Every weapon category must have exactly three shared base Attribute bonuses.', ); } const legacyWeaponBonuses = await ( await database.prepare( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='weapon_base_attribute_bonuses'", ) ).get(); if (legacyWeaponBonuses) { console.log( 'Replacing per-weapon base Attribute bonuses with shared weapon-category profiles.', ); await database.exec('DROP TABLE weapon_base_attribute_bonuses'); } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } } const deletedRoleTable = await ( await database.prepare( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='deleted_roles'", ) ).get(); if (deletedRoleTable) { const deletedRoleRows = await ( await database.prepare('SELECT role_id FROM deleted_roles') ).all(); for (const deletedRole of deletedRoleRows) { const roleId = String(deletedRole.role_id); await ( await database.prepare('DELETE FROM user_roles WHERE role_id=?') ).run(roleId); await ( await database.prepare('DELETE FROM role_permissions WHERE role_id=?') ).run(roleId); await (await database.prepare('DELETE FROM roles WHERE id=?')).run(roleId); } } const profilesForIdentityMigration = await ( await database.prepare(`SELECT user_id,display_name,display_name_key,public_slug FROM user_profiles ORDER BY user_id`) ).all(); const claimedProfileSlugs = new Set( profilesForIdentityMigration .filter((profile) => profile.public_slug) .map((profile) => String(profile.public_slug)), ); const claimedDisplayNameKeys = new Set(); for (const profile of profilesForIdentityMigration) { const userId = String(profile.user_id); let displayName = String(profile.display_name); let key = displayNameKey(displayName); if (claimedDisplayNameKeys.has(key)) { for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) { const candidate = `${displayName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`; const candidateKey = displayNameKey(candidate); if (!claimedDisplayNameKeys.has(candidateKey)) { displayName = candidate; key = candidateKey; break; } } } claimedDisplayNameKeys.add(key); let slug = profile.public_slug ? String(profile.public_slug) : ''; if (!slug || displayName !== String(profile.display_name)) { const base = publicProfileSlugBase(displayName, userId); slug = claimedProfileSlugs.has(base) ? `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}` : base; } claimedProfileSlugs.add(slug); await ( await database.prepare(`UPDATE user_profiles SET display_name=?,display_name_key=?,public_slug=? WHERE user_id=?`) ).run(displayName, key, slug, userId); } await database.exec( 'CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_public_slug_idx ON user_profiles(public_slug)', ); await database.exec( 'CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_display_name_key_idx ON user_profiles(display_name_key)', ); const skillProfileDefinition = await ( await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_attribute_calculation_profiles'", ) ).get(); if ( typeof skillProfileDefinition?.sql === 'string' && !skillProfileDefinition.sql.includes("'flat_damage'") ) { console.log('Adding flat damage to versioned Skill calculation profiles.'); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec( 'ALTER TABLE skill_attribute_calculation_profiles RENAME TO skill_attribute_calculation_profiles_previous', ); await database.exec(`CREATE TABLE skill_attribute_calculation_profiles ( id TEXT PRIMARY KEY, revision_id TEXT NOT NULL REFERENCES calculation_revisions(id) ON DELETE CASCADE, stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), stage_key TEXT NOT NULL CHECK(stage_key IN ( 'flat_damage','engineering','skill_damage','critical_chance','critical_damage', 'multishot_chance','multishot_damage','final_damage', 'cooldown_recovery','duration' )), operator TEXT NOT NULL CHECK(operator IN ('add','multiply')), weight REAL NOT NULL DEFAULT 1, soft_cap REAL, hard_cap REAL, curve_type TEXT NOT NULL DEFAULT 'none' CHECK(curve_type IN ('none','power','smoothstep')), curve_parameter REAL, UNIQUE(revision_id,stat_attribute_id,stage_key), CHECK(soft_cap IS NULL OR soft_cap>=0), CHECK(hard_cap IS NULL OR hard_cap>=0), CHECK(soft_cap IS NULL OR hard_cap IS NULL OR hard_cap>=soft_cap) )`); await database.exec(`INSERT INTO skill_attribute_calculation_profiles SELECT * FROM skill_attribute_calculation_profiles_previous`); await database.exec( 'DROP TABLE skill_attribute_calculation_profiles_previous', ); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } } const triggerColumns = await ( await database.prepare("PRAGMA table_info('talent_triggers')") ).all(); if (triggerColumns.some((column) => column.name === 'supports_uptime')) { console.log( 'Replacing free-form trigger uptime fields with typed trigger actions.', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec('DROP TABLE IF EXISTS talent_triggers_typed'); await database.exec(`CREATE TABLE talent_triggers_typed ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL, action_key TEXT, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`); await database.exec(`INSERT INTO talent_triggers_typed(id,name,description,action_key,active,created_at,updated_at) SELECT id,name,description,NULL,active,created_at,updated_at FROM talent_triggers`); await database.exec('DROP TABLE talent_triggers'); await database.exec( 'ALTER TABLE talent_triggers_typed RENAME TO talent_triggers', ); 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 talent_triggers_name_nocase_idx ON talent_triggers(name COLLATE NOCASE)', ); } const conditionalRuleColumns = await ( await database.prepare("PRAGMA table_info('conditional_effect_rules')") ).all(); if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { console.log( 'Migrating conditional effects to multi-clause mechanic programs.', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec('DROP VIEW IF EXISTS conditional_effect_rule_primary'); await database.exec('DROP TABLE IF EXISTS conditional_effect_clauses'); await database.exec('DROP TABLE IF EXISTS conditional_effect_state_pools'); await database.exec( 'ALTER TABLE conditional_effect_outcomes RENAME TO conditional_effect_outcomes_previous', ); await database.exec( 'ALTER TABLE conditional_effect_rules RENAME TO conditional_effect_rules_previous', ); await database.exec(`CREATE TABLE conditional_effect_rules ( id TEXT PRIMARY KEY, source_type TEXT NOT NULL CHECK(source_type IN ( 'talent','os_protocol_talent','skill_chip_talent','specialization_tree_rank' )), source_id TEXT NOT NULL, configured INTEGER NOT NULL DEFAULT 0 CHECK(configured IN (0,1)), script_version INTEGER NOT NULL DEFAULT 1 CHECK(script_version>0), simulation_readiness TEXT NOT NULL DEFAULT 'ready' CHECK(simulation_readiness IN ( 'ready','requires_assumptions','captured_only' )), scenario_inputs_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(source_type,source_id) )`); await database.exec(`CREATE TABLE conditional_effect_state_pools ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, state_key TEXT NOT NULL, label TEXT NOT NULL, state_kind TEXT NOT NULL DEFAULT 'stack' CHECK(state_kind IN ( 'flag','count','stack','duration','scalar','target_mark','resource' )), state_scope TEXT NOT NULL DEFAULT 'agent' CHECK(state_scope IN ('agent','target')), initial_value REAL NOT NULL DEFAULT 0, minimum_value REAL, maximum_value REAL NOT NULL DEFAULT 1 CHECK(maximum_value>0), best_case_value REAL, duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds>0), expiry_policy TEXT NOT NULL DEFAULT 'clear_all' CHECK(expiry_policy IN ('clear_all','decrement_one','none')), refresh_policy TEXT NOT NULL DEFAULT 'refresh' CHECK(refresh_policy IN ('refresh','extend','ignore')), parameters_json TEXT NOT NULL DEFAULT '{}', sort_order INTEGER NOT NULL CHECK(sort_order>=1), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(rule_id,state_key), UNIQUE(rule_id,sort_order), CHECK(minimum_value IS NULL OR maximum_value>=minimum_value), CHECK(best_case_value IS NULL OR minimum_value IS NULL OR best_case_value>=minimum_value), CHECK(best_case_value IS NULL OR best_case_value<=maximum_value) )`); await database.exec(`CREATE TABLE conditional_effect_clauses ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_key TEXT NOT NULL, label TEXT NOT NULL, trigger_id TEXT NOT NULL REFERENCES talent_triggers(id), parameters_json TEXT NOT NULL DEFAULT '{}', conditions_json TEXT NOT NULL DEFAULT '{"kind":"true"}', reducer_json TEXT NOT NULL DEFAULT '{"kind":"none"}', emission_kind TEXT NOT NULL DEFAULT 'each_match' CHECK(emission_kind IN ( 'each_match','threshold_crossed','while_true','interval_elapsed','value_changed' )), state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, mutations_json TEXT NOT NULL DEFAULT '[]', cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0), output_branch TEXT NOT NULL DEFAULT 'active', sort_order INTEGER NOT NULL CHECK(sort_order>=1), active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(rule_id,clause_key), UNIQUE(rule_id,sort_order) )`); await database.exec(`CREATE TABLE conditional_effect_outcomes ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, effect_key TEXT NOT NULL, variant_key TEXT NOT NULL DEFAULT 'base', outcome_key TEXT NOT NULL DEFAULT 'active', application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo', 'restore_signature_ability_charge','resource_cost' )), effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( 'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' )), stat_attribute_id TEXT REFERENCES stat_attributes(id), value REAL NOT NULL, value_expression_json TEXT, value_basis TEXT NOT NULL DEFAULT 'flat' CHECK(value_basis IN ( 'flat','max_health_percent','current_health_percent','weapon_base_percent' )), value_scaling TEXT NOT NULL DEFAULT 'per_state_unit' CHECK(value_scaling IN ('flat','per_state_unit')), unit TEXT, damage_basis TEXT CHECK(damage_basis IS NULL OR damage_basis IN ('flat','weapon_base_percent')), application_phase TEXT NOT NULL DEFAULT 'while_active' CHECK(application_phase IN ( 'while_active','on_event','after_event','on_enter','on_exit' )), min_state_value REAL, max_state_value REAL, description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), sort_order INTEGER NOT NULL CHECK(sort_order>=1), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(rule_id,variant_key,outcome_key,sort_order), CHECK(min_state_value IS NULL OR min_state_value>=0), CHECK(max_state_value IS NULL OR max_state_value>=0), CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value), CHECK( (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) OR application_kind IN ( 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge','resource_cost' ) ) )`); await database.exec(`INSERT INTO conditional_effect_rules (id,source_type,source_id,configured,script_version,simulation_readiness, scenario_inputs_json,created_at,updated_at) SELECT id,source_type,source_id,configured,1,'ready','[]',created_at,updated_at FROM conditional_effect_rules_previous`); await database.exec(`INSERT INTO conditional_effect_state_pools (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, parameters_json,sort_order,created_at,updated_at) SELECT id||':effect',id,'effect','Effect','stack','agent',0,0,max_stacks,max_stacks, duration_seconds,'clear_all','refresh','{}',1,created_at,updated_at FROM conditional_effect_rules_previous`); await database.exec(`INSERT INTO conditional_effect_clauses (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, active,created_at,updated_at) SELECT id||':primary',id,'primary','Primary',trigger_id,parameters_json,'{"kind":"true"}', '{"kind":"none"}','each_match',id||':effect', '[{"kind":"add","pool":"effect","value":'||stacks_per_trigger||'}]', cooldown_seconds,'active',1,1,created_at,updated_at FROM conditional_effect_rules_previous WHERE trigger_id IS NOT NULL`); await database.exec(`INSERT INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, sort_order,created_at,updated_at) SELECT outcome.id,outcome.rule_id, CASE WHEN rule.trigger_id IS NULL THEN NULL ELSE rule.id||':primary' END, rule.id||':effect',outcome.outcome_key||'-effect-'||outcome.sort_order, outcome.variant_key,outcome.outcome_key,outcome.application_kind, outcome.effect_scope,outcome.stat_attribute_id,outcome.value,NULL, CASE WHEN outcome.damage_basis='weapon_base_percent' THEN 'weapon_base_percent' ELSE 'flat' END, 'per_state_unit',outcome.unit,outcome.damage_basis, CASE WHEN outcome.application_kind='active_modifier' THEN 'while_active' ELSE 'after_event' END, CASE WHEN outcome.application_kind='active_modifier' THEN 1 ELSE NULL END, NULL,CASE WHEN outcome.application_kind='active_modifier' THEN outcome.sort_order ELSE NULL END, outcome.sort_order,outcome.created_at,outcome.created_at FROM conditional_effect_outcomes_previous outcome JOIN conditional_effect_rules_previous rule ON rule.id=outcome.rule_id`); await database.exec('DROP TABLE conditional_effect_outcomes_previous'); await database.exec('DROP TABLE conditional_effect_rules_previous'); 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 conditional_effect_state_pools_rule_idx ON conditional_effect_state_pools(rule_id,sort_order)`); await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_clauses_trigger_idx ON conditional_effect_clauses(trigger_id,rule_id)`); await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); await database.exec(`CREATE VIEW IF NOT EXISTS conditional_effect_rule_primary AS SELECT rule.*, clause.id AS clause_id, clause.trigger_id, clause.parameters_json, clause.cooldown_seconds, pool.id AS state_pool_id, pool.duration_seconds, pool.maximum_value AS max_stacks, COALESCE(CAST(json_extract(clause.mutations_json,'$[0].value') AS REAL),1) AS stacks_per_trigger FROM conditional_effect_rules rule LEFT JOIN conditional_effect_clauses clause ON clause.rule_id=rule.id AND clause.sort_order=1 LEFT JOIN conditional_effect_state_pools pool ON pool.id=clause.state_pool_id`); } const legacyTriggerClauseColumns = await ( await database.prepare("PRAGMA table_info('conditional_effect_clauses')") ).all(); if (legacyTriggerClauseColumns.some((column) => column.name === 'trigger_id')) { const nowForConditionalMigration = new Date().toISOString(); await ( await database.prepare(`INSERT OR IGNORE INTO talent_triggers (id,name,description,action_key,active,created_at,updated_at) VALUES(?,?,?,?,1,?,?)`) ).run( 'trigger-same-target-hits-window', 'Same target hits within a time window', 'Activates after the configured number of hits land on the same target within the configured window.', 'same_target_hits_within_window', nowForConditionalMigration, nowForConditionalMigration, ); await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules (id,source_type,source_id,configured,created_at,updated_at) SELECT 'conditional-talent-'||talent.id,'talent',talent.id,0, talent.created_at,talent.updated_at FROM talents talent`); await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules (id,source_type,source_id,configured,created_at,updated_at) SELECT 'conditional-protocol-'||talent.id,'os_protocol_talent',talent.id,0, talent.created_at,talent.updated_at FROM os_protocol_talents talent`); await database.exec(`INSERT OR IGNORE INTO conditional_effect_rules (id,source_type,source_id,configured,created_at,updated_at) SELECT 'conditional-chip-'||talent.id,'skill_chip_talent',talent.id,0, talent.created_at,talent.updated_at FROM skill_chip_talents talent`); await ( await database.prepare(`INSERT OR IGNORE INTO conditional_effect_rules (id,source_type,source_id,configured,created_at,updated_at) SELECT 'conditional-specialization-'||attachment.id,'specialization_tree_rank',attachment.id,1,?,? FROM specialization_tree_rank_combat_effects attachment`) ).run(nowForConditionalMigration, nowForConditionalMigration); await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, parameters_json,sort_order,created_at,updated_at) SELECT rule.id||':effect',rule.id,'effect','Effect','stack','agent',0,0, COALESCE(talent.max_stacks,1),COALESCE(talent.max_stacks,1), talent.duration_seconds,'clear_all','refresh','{}',1,talent.created_at,talent.updated_at FROM conditional_effect_rules rule JOIN talents talent ON rule.source_type='talent' AND rule.source_id=talent.id`); await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, parameters_json,sort_order,created_at,updated_at) SELECT rule.id||':effect',rule.id,'effect','Effect','stack','agent',0,0,1,1,NULL, 'clear_all','refresh','{}',1,talent.created_at,talent.updated_at FROM conditional_effect_rules rule JOIN os_protocol_talents talent ON rule.source_type='os_protocol_talent' AND rule.source_id=talent.id`); await database.exec(`INSERT OR IGNORE INTO conditional_effect_state_pools (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, parameters_json,sort_order,created_at,updated_at) SELECT rule.id||':effect',rule.id,'effect','Effect','stack','agent',0,0,1,1,NULL, 'clear_all','refresh','{}',1,talent.created_at,talent.updated_at FROM conditional_effect_rules rule JOIN skill_chip_talents talent ON rule.source_type='skill_chip_talent' AND rule.source_id=talent.id`); await ( await database.prepare(`INSERT OR IGNORE INTO conditional_effect_state_pools (id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value, maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy, parameters_json,sort_order,created_at,updated_at) SELECT rule.id||':effect',rule.id,'effect','Effect','stack','agent',0,0, attachment.max_stacks,attachment.max_stacks,attachment.duration_seconds, 'clear_all','refresh','{}',1,?,? FROM conditional_effect_rules rule JOIN specialization_tree_rank_combat_effects attachment ON rule.source_type='specialization_tree_rank' AND rule.source_id=attachment.id`) ).run(nowForConditionalMigration, nowForConditionalMigration); await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, active,created_at,updated_at) SELECT rule.id||':primary',rule.id,'primary','Primary', (SELECT link.trigger_id FROM talent_tiers tier JOIN talent_tier_triggers link ON link.talent_tier_id=tier.id WHERE tier.talent_id=talent.id ORDER BY CASE tier.tier WHEN 'I' THEN 0 ELSE 1 END,link.trigger_id LIMIT 1), '{}','{"kind":"true"}','{"kind":"none"}','each_match',rule.id||':effect', '[{"kind":"add","pool":"effect","value":1}]',talent.cooldown_seconds,'active',1,1, talent.created_at,talent.updated_at FROM conditional_effect_rules rule JOIN talents talent ON rule.source_type='talent' AND rule.source_id=talent.id WHERE EXISTS ( SELECT 1 FROM talent_tiers tier JOIN talent_tier_triggers link ON link.talent_tier_id=tier.id WHERE tier.talent_id=talent.id )`); await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, active,created_at,updated_at) SELECT rule.id||':primary',rule.id,'primary','Primary', (SELECT trigger_id FROM os_protocol_talent_triggers WHERE talent_id=talent.id ORDER BY trigger_id LIMIT 1), '{}','{"kind":"true"}','{"kind":"none"}','each_match',rule.id||':effect', '[{"kind":"add","pool":"effect","value":1}]',NULL,'active',1,1,talent.created_at,talent.updated_at FROM conditional_effect_rules rule JOIN os_protocol_talents talent ON rule.source_type='os_protocol_talent' AND rule.source_id=talent.id WHERE EXISTS (SELECT 1 FROM os_protocol_talent_triggers WHERE talent_id=talent.id)`); await database.exec(`INSERT OR IGNORE INTO conditional_effect_clauses (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, active,created_at,updated_at) SELECT rule.id||':primary',rule.id,'primary','Primary', (SELECT trigger_id FROM skill_chip_talent_triggers WHERE talent_id=talent.id ORDER BY trigger_id LIMIT 1), '{}','{"kind":"true"}','{"kind":"none"}','each_match',rule.id||':effect', '[{"kind":"add","pool":"effect","value":1}]',NULL,'active',1,1,talent.created_at,talent.updated_at FROM conditional_effect_rules rule JOIN skill_chip_talents talent ON rule.source_type='skill_chip_talent' AND rule.source_id=talent.id WHERE EXISTS (SELECT 1 FROM skill_chip_talent_triggers WHERE talent_id=talent.id)`); await ( await database.prepare(`INSERT OR IGNORE INTO conditional_effect_clauses (id,rule_id,clause_key,label,trigger_id,parameters_json,conditions_json,reducer_json, emission_kind,state_pool_id,mutations_json,cooldown_seconds,output_branch,sort_order, active,created_at,updated_at) SELECT rule.id||':primary',rule.id,'primary','Primary','trigger-same-target-hits-window', '{"hitsRequired":'||attachment.trigger_count||',"windowSeconds":'||attachment.trigger_window_seconds||'}', '{"kind":"true"}','{"kind":"sliding_count","threshold":'||attachment.trigger_count|| ',"windowSeconds":'||attachment.trigger_window_seconds||'}', 'threshold_crossed',rule.id||':effect', '[{"kind":"add","pool":"effect","value":'||attachment.stacks_per_trigger||'}]', NULL,'active',1,1,?,? FROM conditional_effect_rules rule JOIN specialization_tree_rank_combat_effects attachment ON rule.source_type='specialization_tree_rank' AND rule.source_id=attachment.id`) ).run(nowForConditionalMigration, nowForConditionalMigration); await database.exec(`UPDATE conditional_effect_clauses SET parameters_json=REPLACE(parameters_json,'"count":','"hitsRequired":') WHERE rule_id IN ( SELECT id FROM conditional_effect_rules WHERE source_type='specialization_tree_rank' AND configured=1 ) AND parameters_json LIKE '%"count":%'`); await database.exec(`WITH canonical_talent_effects AS ( SELECT ranked.rule_id,ranked.stat_attribute_id, ROW_NUMBER() OVER( PARTITION BY ranked.rule_id ORDER BY COALESCE(ranked.tier_one_order,ranked.any_tier_order),ranked.stat_attribute_id ) AS effect_order FROM ( SELECT rule.id AS rule_id,effect.stat_attribute_id, MIN(CASE WHEN tier.tier='I' THEN effect.created_at||':'||effect.id END) AS tier_one_order, MIN(effect.created_at||':'||effect.id) AS any_tier_order FROM talent_tier_effects effect JOIN talent_tiers tier ON tier.id=effect.talent_tier_id JOIN talents talent ON talent.id=tier.talent_id JOIN conditional_effect_rules rule ON rule.source_type='talent' AND rule.source_id=talent.id GROUP BY rule.id,effect.stat_attribute_id ) ranked ) INSERT OR IGNORE INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, sort_order,created_at,updated_at) SELECT 'conditional-outcome-talent-'||effect.id,rule.id,rule.id||':primary',rule.id||':effect', 'active-effect-'||canonical.effect_order, tier.tier,'active','active_modifier', CASE talent.category WHEN 'Weapon' THEN 'equipped_weapon' ELSE 'self' END, effect.stat_attribute_id,effect.value,NULL,'flat','per_state_unit',attribute.unit,NULL, 'while_active',1,NULL, canonical.effect_order, canonical.effect_order, effect.created_at,effect.created_at FROM talent_tier_effects effect JOIN talent_tiers tier ON tier.id=effect.talent_tier_id JOIN talents talent ON talent.id=tier.talent_id JOIN conditional_effect_rules rule ON rule.source_type='talent' AND rule.source_id=talent.id JOIN canonical_talent_effects canonical ON canonical.rule_id=rule.id AND canonical.stat_attribute_id=effect.stat_attribute_id JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id`); await database.exec(`INSERT OR IGNORE INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, sort_order,created_at,updated_at) SELECT 'conditional-outcome-protocol-'||effect.id,rule.id,rule.id||':primary',rule.id||':effect', 'active-effect-'||effect.sort_order,'base','active','active_modifier','self', effect.stat_attribute_id,effect.base_value,NULL,'flat','per_state_unit',attribute.unit,NULL, 'while_active',1,NULL,effect.sort_order,effect.sort_order,talent.created_at,talent.created_at FROM os_protocol_talent_effects effect JOIN os_protocol_talents talent ON talent.id=effect.talent_id JOIN conditional_effect_rules rule ON rule.source_type='os_protocol_talent' AND rule.source_id=talent.id JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id`); await database.exec(`INSERT OR IGNORE INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, sort_order,created_at,updated_at) SELECT 'conditional-outcome-chip-'||effect.id,rule.id,rule.id||':primary',rule.id||':effect', 'active-effect-'||effect.sort_order,'minimum','active','active_modifier','assigned_ability', effect.stat_attribute_id,effect.min_value,NULL,'flat','per_state_unit',attribute.unit,NULL, 'while_active',1,NULL,effect.sort_order,effect.sort_order,talent.created_at,talent.created_at FROM skill_chip_talent_effects effect JOIN skill_chip_talents talent ON talent.id=effect.talent_id JOIN conditional_effect_rules rule ON rule.source_type='skill_chip_talent' AND rule.source_id=talent.id JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id`); await ( await database.prepare(`INSERT OR IGNORE INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,application_kind, effect_scope,stat_attribute_id,value,value_expression_json,value_basis,value_scaling, unit,damage_basis,application_phase,min_state_value,max_state_value,description_slot, sort_order,created_at,updated_at) SELECT 'conditional-outcome-specialization-'||modifier.id,rule.id,rule.id||':primary',rule.id||':effect', 'active-effect-'||modifier.modifier_order,'base','active','active_modifier','self', modifier.stat_attribute_id,modifier.value_per_stack,NULL,'flat','per_state_unit',modifier.unit,NULL, 'while_active',1,NULL,modifier.modifier_order,modifier.modifier_order,?,? FROM specialization_tree_rank_combat_modifiers modifier JOIN conditional_effect_rules rule ON rule.source_type='specialization_tree_rank' JOIN specialization_tree_rank_combat_effects attachment ON attachment.id=rule.source_id AND attachment.id=modifier.rank_combat_effect_id`) ).run(nowForConditionalMigration, nowForConditionalMigration); } const sourceOwnedClauseColumns = await ( await database.prepare("PRAGMA table_info('conditional_effect_clauses')") ).all(); if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { console.log( 'Migrating mechanic clauses from Trigger references to source-owned events.', ); const legacyClauses = await ( await database.prepare(` SELECT clause.*,trigger.action_key FROM conditional_effect_clauses clause LEFT JOIN talent_triggers trigger ON trigger.id=clause.trigger_id ORDER BY clause.rule_id,clause.sort_order `) ).all(); const jsonObject = (value, fallback) => { try { const parsed = JSON.parse(String(value ?? '')); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback; } catch { return fallback; } }; const eventReference = (path) => ({ source: 'event', path }); const targetExists = { kind: 'exists', value: eventReference('targetId') }; const combinePredicates = (left, right) => { if (!left || left.kind === 'true') return right ?? { kind: 'true' }; if (!right || right.kind === 'true') return left; return { kind: 'all', predicates: [left, right] }; }; const actionMapping = (action, parameters) => { switch (action) { case 'same_target_hits_within_window': return { eventKind: 'weapon_hit', predicate: targetExists, reducer: { kind: 'sliding_count', threshold: Number(parameters.hitsRequired ?? 1), windowSeconds: Number(parameters.windowSeconds ?? 1), reset: 'zero', groupBy: eventReference('targetId'), }, }; case 'distinct_enemies_within_window': return { eventKind: 'weapon_hit', predicate: targetExists, reducer: { kind: 'distinct_count', threshold: Number(parameters.enemiesRequired ?? 1), windowSeconds: Number(parameters.windowSeconds ?? 1), distinctBy: eventReference('targetId'), reset: 'zero', }, }; case 'consecutive_same_target_hits': return { eventKind: 'weapon_hit', predicate: targetExists, reducer: { kind: 'consecutive_count', threshold: Number(parameters.hitsRequired ?? 1), groupBy: eventReference('targetId'), reset: 'zero', }, }; case 'continuous_firing_duration': return { eventKind: 'firing_started', reducer: { kind: 'duration', seconds: Number(parameters.seconds ?? 1), }, capturedOnly: true, }; case 'while_firing': return { eventKind: 'firing_started', capturedOnly: true }; case 'health_threshold_bands': return { eventKind: 'tick', capturedOnly: true }; case 'headshot': return { eventKind: 'weapon_hit', predicate: { kind: 'compare', left: eventReference('values.headshot'), operator: 'eq', right: true, }, }; case 'headshot_kill': return { eventKind: 'enemy_killed', predicate: { kind: 'compare', left: eventReference('values.headshot'), operator: 'eq', right: true, }, }; case 'weapon_kill': return { eventKind: 'enemy_killed' }; case 'target_hit_within_distance': return { eventKind: 'weapon_hit', predicate: { kind: 'all', predicates: [ targetExists, { kind: 'compare', left: eventReference('values.distanceMeters'), operator: 'lte', right: Number(parameters.meters ?? 0), }, ], }, }; case 'damage_taken': return { eventKind: 'health_damage_taken' }; case 'weapon_swap': return { eventKind: 'weapon_swapped' }; case 'reload': return { eventKind: 'reload_started' }; case 'within_distance': return { eventKind: 'proximity_snapshot', predicate: { kind: 'compare', left: eventReference('values.distanceMeters'), operator: 'lte', right: Number(parameters.meters ?? 0), }, capturedOnly: true, }; case 'extra_health_active': return { eventKind: 'tick', predicate: { kind: 'compare', left: { source: 'combat', path: 'values.extraHealth' }, operator: 'gt', right: 0, }, capturedOnly: true, }; default: if ( [ 'shot_fired', 'weapon_hit', 'skill_deployed', 'skill_hit', 'agent_hit', 'health_damage_taken', 'damage_dealt', 'enemy_killed', 'assist', 'healing_applied', 'extra_health_granted', 'movement_snapshot', 'proximity_snapshot', 'firing_started', 'firing_stopped', 'reload_started', 'reload_completed', 'weapon_swapped', 'tick', ].includes(String(action)) ) return { eventKind: String(action) }; return null; } }; const migratedClauses = legacyClauses.map((clause) => { const parameters = jsonObject(clause.parameters_json, {}); const existingPredicate = jsonObject(clause.conditions_json, { kind: 'true', }); const existingReducer = jsonObject(clause.reducer_json, { kind: 'none' }); const mapping = actionMapping(String(clause.action_key ?? ''), parameters); if (!mapping) { throw new Error( `Cannot migrate mechanic clause ${clause.id}: unsupported Trigger action ${clause.action_key ?? 'NULL'}.`, ); } return { ...clause, event_kind: mapping.eventKind, recipe_key: String(clause.action_key ?? '') || null, recipe_parameters_json: JSON.stringify(parameters), conditions_json: JSON.stringify( combinePredicates(mapping.predicate, existingPredicate), ), reducer_json: JSON.stringify( existingReducer.kind === 'none' && mapping.reducer ? mapping.reducer : existingReducer, ), captured_only: Boolean(mapping.capturedOnly), }; }); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec('DROP VIEW IF EXISTS conditional_effect_rule_primary'); await database.exec( 'ALTER TABLE conditional_effect_clauses RENAME TO conditional_effect_clauses_triggered', ); await database.exec( 'ALTER TABLE conditional_effect_outcomes RENAME TO conditional_effect_outcomes_triggered', ); await database.exec(`CREATE TABLE conditional_effect_clauses ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_key TEXT NOT NULL, label TEXT NOT NULL, event_kind TEXT NOT NULL, recipe_key TEXT, recipe_parameters_json TEXT NOT NULL DEFAULT '{}', conditions_json TEXT NOT NULL DEFAULT '{"kind":"true"}', reducer_json TEXT NOT NULL DEFAULT '{"kind":"none"}', weight_expression_json TEXT, emission_kind TEXT NOT NULL DEFAULT 'each_match' CHECK(emission_kind IN ( 'each_match','threshold_crossed','while_true','interval_elapsed','value_changed' )), state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, mutations_json TEXT NOT NULL DEFAULT '[]', cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0), output_branch TEXT NOT NULL DEFAULT 'active', sort_order INTEGER NOT NULL CHECK(sort_order>=1), active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(rule_id,clause_key), UNIQUE(rule_id,sort_order) )`); const insertClause = await database.prepare(`INSERT INTO conditional_effect_clauses (id,rule_id,clause_key,label,event_kind,recipe_key,recipe_parameters_json, conditions_json,reducer_json,weight_expression_json,emission_kind,state_pool_id, mutations_json,cooldown_seconds,output_branch,sort_order,active,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`); for (const clause of migratedClauses) { await insertClause.run( clause.id, clause.rule_id, clause.clause_key, clause.label, clause.event_kind, clause.recipe_key, clause.recipe_parameters_json, clause.conditions_json, clause.reducer_json, null, clause.emission_kind, clause.state_pool_id, clause.mutations_json, clause.cooldown_seconds, clause.output_branch, clause.sort_order, clause.active, clause.created_at, clause.updated_at, ); if (clause.captured_only) { await ( await database.prepare( "UPDATE conditional_effect_rules SET simulation_readiness='captured_only' WHERE id=?", ) ).run(clause.rule_id); } } await database.exec(`CREATE TABLE conditional_effect_outcomes ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, effect_key TEXT NOT NULL, variant_key TEXT NOT NULL DEFAULT 'base', outcome_key TEXT NOT NULL DEFAULT 'active', application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo', 'restore_signature_ability_charge','resource_cost','marker' )), effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( 'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' )), stat_attribute_id TEXT REFERENCES stat_attributes(id), value REAL NOT NULL, value_expression_json TEXT, value_basis TEXT NOT NULL DEFAULT 'flat' CHECK(value_basis IN ( 'flat','max_health_percent','current_health_percent','weapon_base_percent' )), value_scaling TEXT NOT NULL DEFAULT 'per_state_unit' CHECK(value_scaling IN ('flat','per_state_unit')), unit TEXT, damage_basis TEXT CHECK(damage_basis IS NULL OR damage_basis IN ('flat','weapon_base_percent')), application_phase TEXT NOT NULL DEFAULT 'while_active' CHECK(application_phase IN ( 'while_active','on_event','after_event','on_enter','on_exit' )), combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')), resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ( 'health','armor','ammo','signature_ability_charge' )), min_state_value REAL, max_state_value REAL, description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), sort_order INTEGER NOT NULL CHECK(sort_order>=1), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(rule_id,variant_key,outcome_key,sort_order), CHECK(min_state_value IS NULL OR min_state_value>=0), CHECK(max_state_value IS NULL OR max_state_value>=0), CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value), CHECK( (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) OR application_kind IN ( 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge', 'resource_cost','marker' ) ) )`); await database.exec(`INSERT INTO conditional_effect_outcomes (id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, resource_kind,min_state_value,max_state_value,description_slot,sort_order, created_at,updated_at) SELECT id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,'current', CASE application_kind WHEN 'restore_health' THEN 'health' WHEN 'restore_armor' THEN 'armor' WHEN 'restore_ammo' THEN 'ammo' WHEN 'restore_signature_ability_charge' THEN 'signature_ability_charge' ELSE NULL END, min_state_value,max_state_value,description_slot,sort_order,created_at,updated_at FROM conditional_effect_outcomes_triggered`); await database.exec('DROP TABLE conditional_effect_outcomes_triggered'); await database.exec('DROP TABLE conditional_effect_clauses_triggered'); await database.exec('DROP TABLE IF EXISTS talent_tier_triggers'); await database.exec('DROP TABLE IF EXISTS os_protocol_talent_triggers'); await database.exec('DROP TABLE IF EXISTS skill_chip_talent_triggers'); await database.exec('DROP TABLE IF EXISTS talent_triggers'); 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 conditional_effect_clauses_event_idx ON conditional_effect_clauses(event_kind,rule_id)`); await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); } // Legacy conditional-mechanic table rebuilds above predate delivery metadata. // Recheck after those migrations so their replacement tables receive the // additive columns too. const postRebuildOutcomeColumns = await ( await database.prepare("PRAGMA table_info('conditional_effect_outcomes')") ).all(); if ( !postRebuildOutcomeColumns.some((column) => column.name === 'delivery_mode') ) { console.log('Adding outcome delivery modes to conditional mechanics.'); await database.exec(`ALTER TABLE conditional_effect_outcomes ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous'))`); } if ( !postRebuildOutcomeColumns.some( (column) => column.name === 'delivery_duration_seconds', ) ) { console.log('Adding continuous-delivery durations to conditional mechanics.'); await database.exec(`ALTER TABLE conditional_effect_outcomes ADD COLUMN delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0)`); } const legacyTalentOutcomes = await ( await database.prepare(` SELECT outcome.id,outcome.rule_id,outcome.variant_key,outcome.stat_attribute_id, outcome.effect_key,outcome.description_slot,outcome.sort_order FROM conditional_effect_outcomes outcome JOIN conditional_effect_rules rule ON rule.id=outcome.rule_id WHERE rule.source_type='talent' AND outcome.id LIKE 'conditional-outcome-talent-%' AND outcome.variant_key IN ('I','II') ORDER BY outcome.rule_id,outcome.variant_key,outcome.sort_order,outcome.id `) ).all(); const legacyTalentOutcomesByRule = new Map(); for (const outcome of legacyTalentOutcomes) { const rows = legacyTalentOutcomesByRule.get(outcome.rule_id) ?? []; rows.push(outcome); legacyTalentOutcomesByRule.set(outcome.rule_id, rows); } const legacyTalentOutcomeRepairs = []; for (const [ruleId, rows] of legacyTalentOutcomesByRule) { const tierOne = rows .filter((row) => row.variant_key === 'I') .sort( (left, right) => Number(left.description_slot ?? left.sort_order) - Number(right.description_slot ?? right.sort_order) || String(left.id).localeCompare(String(right.id)), ); const tierTwo = rows.filter((row) => row.variant_key === 'II'); if (!tierOne.length || !tierTwo.length) continue; const tierOneAttributes = new Set( tierOne.map((row) => String(row.stat_attribute_id ?? '')), ); const tierTwoAttributes = new Set( tierTwo.map((row) => String(row.stat_attribute_id ?? '')), ); if ( tierOneAttributes.size !== tierTwoAttributes.size || [...tierOneAttributes].some( (attributeId) => !tierTwoAttributes.has(attributeId), ) ) { throw new Error( `Cannot repair legacy talent mechanic ${ruleId}: Tier I and Tier II Attributes differ.`, ); } const slotByAttribute = new Map( tierOne.map((row, index) => [String(row.stat_attribute_id), index + 1]), ); for (const row of rows) { const slot = slotByAttribute.get(String(row.stat_attribute_id)); if (!slot) continue; if ( row.effect_key !== `active-effect-${slot}` || Number(row.description_slot) !== slot || Number(row.sort_order) !== slot ) { legacyTalentOutcomeRepairs.push({ id: row.id, slot }); } } } if (legacyTalentOutcomeRepairs.length) { console.log( `Repairing ${legacyTalentOutcomeRepairs.length} legacy Talent mechanic value pairings.`, ); await database.exec('BEGIN'); try { const temporaryOrder = await database.prepare( 'UPDATE conditional_effect_outcomes SET sort_order=sort_order+1000000 WHERE id=?', ); for (const repair of legacyTalentOutcomeRepairs) await temporaryOrder.run(repair.id); const repairOutcome = await database.prepare(` UPDATE conditional_effect_outcomes SET effect_key=?,description_slot=?,sort_order=?,updated_at=? WHERE id=? `); const repairedAt = new Date().toISOString(); for (const repair of legacyTalentOutcomeRepairs) { await repairOutcome.run( `active-effect-${repair.slot}`, repair.slot, repair.slot, repairedAt, repair.id, ); } await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } } 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 triggerProposalDefinition = await ( await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'", ) ).get(); if ( typeof triggerProposalDefinition?.sql === 'string' && triggerProposalDefinition.sql.includes("'triggers'") ) { console.log( 'Retiring standalone Trigger proposals in favor of source-owned mechanic programs.', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec(`CREATE TABLE data_change_proposals_source_owned ( 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_source_owned SELECT id, CASE WHEN module='triggers' THEN 'talents' ELSE module END, CASE WHEN module='triggers' THEN 'legacyTrigger.'||action ELSE action END, entity_id,draft_entity_id,applied_entity_id,summary,payload_json, CASE WHEN module='triggers' AND status='pending' THEN 'failed' ELSE status END, proposed_by,reviewed_by, CASE WHEN module='triggers' AND status='pending' THEN 'Standalone Trigger proposals were retired when mechanics became source-owned.' ELSE review_note END, created_at, CASE WHEN module='triggers' AND status='pending' THEN CURRENT_TIMESTAMP ELSE reviewed_at END, applied_at FROM data_change_proposals`); await database.exec('DROP TABLE data_change_proposals'); await database.exec( 'ALTER TABLE data_change_proposals_source_owned RENAME TO data_change_proposals', ); 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 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 weaponModSlotIconDefinition = await ( await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'", ) ).get(); if ( typeof weaponModSlotIconDefinition?.sql === 'string' && !weaponModSlotIconDefinition.sql.includes("'weapon_mod_slot'") ) { console.log('Adding shared Weapon Mod slot icon storage support.'); await database.exec(`CREATE TABLE game_data_icons_weapon_mod_slots ( entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')), 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_weapon_mod_slots 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_weapon_mod_slots 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', weapon_mod_slot: 'Weapon Mods', }; 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', weapon_mod_slot: 'Weapon Mod Slot', }; const weaponModSlotNames = { '28ffc3b1-5d98-4273-a9af-67dbff3e5c76': 'Optics', 'c4598f51-2cb0-4a20-a3a7-6d8983a26598': 'Muzzle', '85ca3493-1871-4d22-950a-ed718fd547e0': 'Underbarrel', '4d809a35-8285-4bff-be98-a0effe1d257e': 'Magazine', '0e157cfe-975c-441d-8f49-72983161e7b1': 'Barrel', '551fd7c8-3e5b-4c89-8437-9b901e89e8aa': 'Upper Receiver', '72573367-6e91-4312-9cc3-710ede4f078e': 'Lower Receiver', '8dda5edb-6079-4a23-a556-cd9be4521c70': 'Trigger', }; 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', ], }; } case 'weapon_mod_slot': { const name = weaponModSlotNames[entityId]; return { name: name ? `${name} Slot` : null, tags: ['weapon mod', 'slot', name], }; } 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'); } for (const kind of ['weapon', 'gear']) { const optionTable = `${kind}_mod_secondary_options`; const valueTable = `${kind}_mod_secondary_quality_values`; const optionColumns = await ( await database.prepare(`PRAGMA table_info('${optionTable}')`) ).all(); if (!optionColumns.some((column) => column.name === `${kind}_mod_id`)) continue; const conflictingRanges = await ( await database.prepare(`SELECT stat_attribute_id,quality, COUNT(DISTINCT CAST(min_value AS TEXT)||'/'||CAST(max_value AS TEXT)) AS variants FROM ${valueTable} WHERE quality IN ('Specialized','Superior','High-end') GROUP BY stat_attribute_id,quality HAVING variants>1`) ).all(); if (conflictingRanges.length) { throw new Error( `Cannot create the shared ${kind} Mod secondary pool because existing per-Mod ranges disagree. Reconcile ${conflictingRanges.map((row) => `${row.stat_attribute_id}/${row.quality}`).join(', ')} first.`, ); } const incompleteOptions = await ( await database.prepare(`SELECT options.stat_attribute_id, COUNT(DISTINCT CASE WHEN values_row.quality IN ('Specialized','Superior','High-end') THEN values_row.quality END) AS qualities FROM ${optionTable} options LEFT JOIN ${valueTable} values_row ON values_row.${kind}_mod_id=options.${kind}_mod_id AND values_row.stat_attribute_id=options.stat_attribute_id GROUP BY options.${kind}_mod_id,options.stat_attribute_id HAVING qualities<>3`) ).all(); if (incompleteOptions.length) { throw new Error( `Cannot create the shared ${kind} Mod secondary pool because ${incompleteOptions.map((row) => row.stat_attribute_id).join(', ')} does not have all applicable quality ranges.`, ); } console.log( `Converting ${kind} Mod secondary Attributes to one shared level-scaled pool.`, ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec(`CREATE TABLE ${optionTable}_shared ( stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id) )`); await database.exec(`CREATE TABLE ${valueTable}_shared ( stat_attribute_id TEXT NOT NULL, quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')), level_1_min REAL NOT NULL, level_1_max REAL NOT NULL, level_20_min REAL NOT NULL, level_20_max REAL NOT NULL, PRIMARY KEY(stat_attribute_id,quality), FOREIGN KEY(stat_attribute_id) REFERENCES ${optionTable}_shared(stat_attribute_id) ON DELETE CASCADE, CHECK(level_1_max>=level_1_min), CHECK(level_20_max>=level_20_min), CHECK(level_20_min>=level_1_min), CHECK(level_20_max>=level_1_max) )`); await database.exec(`INSERT INTO ${optionTable}_shared(stat_attribute_id) SELECT DISTINCT stat_attribute_id FROM ${optionTable}`); await database.exec(`INSERT INTO ${valueTable}_shared( stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max ) SELECT stat_attribute_id,quality,MIN(min_value),MAX(max_value),MIN(min_value),MAX(max_value) FROM ${valueTable} WHERE quality IN ('Specialized','Superior','High-end') GROUP BY stat_attribute_id,quality`); await database.exec(`DROP TABLE ${valueTable}`); await database.exec(`DROP TABLE ${optionTable}`); await database.exec( `ALTER TABLE ${optionTable}_shared RENAME TO ${optionTable}`, ); await database.exec( `ALTER TABLE ${valueTable}_shared RENAME TO ${valueTable}`, ); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } } const skillChipPrimaryOptionColumns = await ( await database.prepare("PRAGMA table_info('skill_chip_primary_options')") ).all(); if ( skillChipPrimaryOptionColumns.some( (column) => column.name === 'skill_chip_set_id', ) ) { const fixedSkillChipAttributes = await ( await database.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active FROM stat_attributes WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')`) ).get(); if ( Number(fixedSkillChipAttributes?.total) !== 4 || Number(fixedSkillChipAttributes?.active) !== 4 ) throw new Error( 'Skill Chip profile migration requires exactly four active fixed primary Attributes.', ); console.log( 'Converting Skill Chip primary and secondary options to shared quality profiles.', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec( 'DROP TABLE IF EXISTS skill_chip_primary_quality_values', ); await database.exec( 'DROP TABLE IF EXISTS skill_chip_secondary_quality_values', ); await database.exec(`CREATE TABLE skill_chip_primary_options_shared ( stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id) )`); await database.exec(`CREATE TABLE skill_chip_primary_quality_values_shared ( stat_attribute_id TEXT NOT NULL, quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')), min_value REAL NOT NULL, max_value REAL NOT NULL, PRIMARY KEY(stat_attribute_id,quality), FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options_shared(stat_attribute_id) ON DELETE CASCADE, CHECK(max_value>=min_value), CHECK(min_value>=0) )`); await database.exec(`CREATE TABLE skill_chip_secondary_options_shared ( stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id) )`); await database.exec(`CREATE TABLE skill_chip_secondary_quality_values_shared ( stat_attribute_id TEXT NOT NULL, quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')), min_value REAL NOT NULL, max_value REAL NOT NULL, PRIMARY KEY(stat_attribute_id,quality), FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options_shared(stat_attribute_id) ON DELETE CASCADE, CHECK(max_value>=min_value), CHECK(min_value>=0) )`); await database.exec(`INSERT INTO skill_chip_primary_options_shared(stat_attribute_id) SELECT id FROM stat_attributes WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity') AND active=1`); await database.exec(`INSERT INTO skill_chip_primary_quality_values_shared( stat_attribute_id,quality,min_value,max_value ) SELECT attributes.id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max FROM stat_attributes attributes CROSS JOIN ( SELECT 'Standard' quality UNION ALL SELECT 'Specialized' UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end' ) qualities WHERE attributes.name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity') AND attributes.active=1 AND attributes.skill_chip_min IS NOT NULL AND attributes.skill_chip_max IS NOT NULL`); await database.exec(`INSERT INTO skill_chip_secondary_options_shared(stat_attribute_id) SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options`); await database.exec(`INSERT INTO skill_chip_secondary_quality_values_shared( stat_attribute_id,quality,min_value,max_value ) SELECT options.stat_attribute_id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max FROM (SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options) options JOIN stat_attributes attributes ON attributes.id=options.stat_attribute_id CROSS JOIN ( SELECT 'Specialized' quality UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end' ) qualities WHERE attributes.skill_chip_min IS NOT NULL AND attributes.skill_chip_max IS NOT NULL`); await database.exec('DROP TABLE skill_chip_primary_options'); await database.exec('DROP TABLE skill_chip_secondary_options'); await database.exec( 'ALTER TABLE skill_chip_primary_options_shared RENAME TO skill_chip_primary_options', ); await database.exec( 'ALTER TABLE skill_chip_primary_quality_values_shared RENAME TO skill_chip_primary_quality_values', ); await database.exec( 'ALTER TABLE skill_chip_secondary_options_shared RENAME TO skill_chip_secondary_options', ); await database.exec( 'ALTER TABLE skill_chip_secondary_quality_values_shared RENAME TO skill_chip_secondary_quality_values', ); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { await database.exec('PRAGMA foreign_keys=ON'); } } await database.exec(`INSERT OR IGNORE INTO skill_chip_primary_options(stat_attribute_id) SELECT id FROM stat_attributes WHERE active=1 AND name IN ( 'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity' )`); const skillChipRangeAttributeColumns = await ( await database.prepare("PRAGMA table_info('stat_attributes')") ).all(); if ( skillChipRangeAttributeColumns.some( (column) => column.name === 'skill_chip_min', ) ) { console.log( 'Removing obsolete Attribute-level Skill Chip ranges in favor of shared quality profiles.', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec( 'DROP INDEX IF EXISTS stat_attributes_name_unit_nocase_idx', ); await database.exec(`CREATE TABLE stat_attributes_current ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, type TEXT NOT NULL CHECK(type IN ('Offensive','Defensive')), category TEXT NOT NULL CHECK(category IN ('Weapon','Skill','Character')), calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive','Multiplicative')), unit TEXT NOT NULL DEFAULT '', gear_min REAL, gear_max REAL, gear_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50), weapon_min REAL, weapon_max REAL, weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50), cap_value REAL, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min<=gear_max)), CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min<=weapon_max)), CHECK(cap_value IS NULL OR cap_value>=0) )`); await database.exec(`INSERT INTO stat_attributes_current( id,name,description,type,category,calculation_type,unit, gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level, cap_value,active,created_at,updated_at ) SELECT id,name,description,type,category,calculation_type,unit, gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level, cap_value,active,created_at,updated_at FROM stat_attributes`); await database.exec('DROP TABLE stat_attributes'); await database.exec( 'ALTER TABLE stat_attributes_current RENAME TO stat_attributes', ); await database.exec( 'CREATE UNIQUE INDEX stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)', ); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } finally { 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 conditionalOutcomeSchema = await ( await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='conditional_effect_outcomes'", ) ).get(); if ( conditionalOutcomeSchema && !String(conditionalOutcomeSchema.sql ?? '').includes( 'signature_ability_charge', ) ) { console.log( 'Adding Signature Ability Charge as a conditional-mechanic resource.', ); await database.exec( 'DROP INDEX IF EXISTS conditional_effect_outcomes_rule_idx', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec( 'DROP TABLE IF EXISTS conditional_effect_outcomes_signature_charge', ); await database.exec(`CREATE TABLE conditional_effect_outcomes_signature_charge ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, effect_key TEXT NOT NULL, variant_key TEXT NOT NULL DEFAULT 'base', outcome_key TEXT NOT NULL DEFAULT 'active', application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo', 'restore_signature_ability_charge','resource_cost','marker' )), effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( 'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' )), stat_attribute_id TEXT REFERENCES stat_attributes(id), value REAL NOT NULL, value_expression_json TEXT, value_basis TEXT NOT NULL DEFAULT 'flat' CHECK(value_basis IN ( 'flat','max_health_percent','current_health_percent','weapon_base_percent' )), value_scaling TEXT NOT NULL DEFAULT 'per_state_unit' CHECK(value_scaling IN ('flat','per_state_unit')), unit TEXT, damage_basis TEXT CHECK(damage_basis IS NULL OR damage_basis IN ('flat','weapon_base_percent')), application_phase TEXT NOT NULL DEFAULT 'while_active' CHECK(application_phase IN ( 'while_active','on_event','after_event','on_enter','on_exit' )), combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')), resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ( 'health','armor','ammo','signature_ability_charge' )), delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')), delivery_duration_seconds REAL, min_state_value REAL, max_state_value REAL, description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), sort_order INTEGER NOT NULL CHECK(sort_order>=1), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(rule_id,variant_key,outcome_key,sort_order), CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0), CHECK(delivery_mode='instant' OR ( application_kind IN ( 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge' ) AND delivery_duration_seconds IS NOT NULL )), CHECK(min_state_value IS NULL OR min_state_value>=0), CHECK(max_state_value IS NULL OR max_state_value>=0), CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value), CHECK( (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) OR application_kind IN ( 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge', 'resource_cost','marker' ) ) )`); await database.exec(`INSERT INTO conditional_effect_outcomes_signature_charge ( id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, resource_kind,delivery_mode,delivery_duration_seconds,min_state_value,max_state_value, description_slot,sort_order,created_at,updated_at ) SELECT id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, resource_kind,delivery_mode,delivery_duration_seconds,min_state_value,max_state_value, description_slot,sort_order,created_at,updated_at FROM conditional_effect_outcomes`); await database.exec('DROP TABLE conditional_effect_outcomes'); await database.exec( 'ALTER TABLE conditional_effect_outcomes_signature_charge RENAME TO conditional_effect_outcomes', ); 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 conditional_effect_outcomes_rule_idx ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); } const reloadOutcomeSchema = await ( await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='conditional_effect_outcomes'", ) ).get(); if ( reloadOutcomeSchema && !String(reloadOutcomeSchema.sql ?? '').includes("'weapon_reload'") ) { console.log( 'Adding inactive-weapon reload actions to conditional mechanics.', ); await database.exec( 'DROP INDEX IF EXISTS conditional_effect_outcomes_rule_idx', ); await database.exec('PRAGMA foreign_keys=OFF'); await database.exec('BEGIN'); try { await database.exec( 'DROP TABLE IF EXISTS conditional_effect_outcomes_weapon_reload', ); await database.exec(`CREATE TABLE conditional_effect_outcomes_weapon_reload ( id TEXT PRIMARY KEY, rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, effect_key TEXT NOT NULL, variant_key TEXT NOT NULL DEFAULT 'base', outcome_key TEXT NOT NULL DEFAULT 'active', application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo', 'restore_signature_ability_charge','resource_cost','weapon_reload','marker' )), effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( 'self','equipped_weapon','inactive_weapon','target','allies','self_and_allies', 'assigned_ability','all_skills' )), stat_attribute_id TEXT REFERENCES stat_attributes(id), value REAL NOT NULL, value_expression_json TEXT, value_basis TEXT NOT NULL DEFAULT 'flat' CHECK(value_basis IN ( 'flat','max_health_percent','current_health_percent','weapon_base_percent' )), value_scaling TEXT NOT NULL DEFAULT 'per_state_unit' CHECK(value_scaling IN ('flat','per_state_unit')), unit TEXT, damage_basis TEXT CHECK(damage_basis IS NULL OR damage_basis IN ('flat','weapon_base_percent')), application_phase TEXT NOT NULL DEFAULT 'while_active' CHECK(application_phase IN ( 'while_active','on_event','after_event','on_enter','on_exit' )), combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')), resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ( 'health','armor','ammo','signature_ability_charge' )), delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')), delivery_duration_seconds REAL, min_state_value REAL, max_state_value REAL, description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), sort_order INTEGER NOT NULL CHECK(sort_order>=1), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(rule_id,variant_key,outcome_key,sort_order), CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0), CHECK(delivery_mode='instant' OR ( application_kind IN ( 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge' ) AND delivery_duration_seconds IS NOT NULL )), CHECK(min_state_value IS NULL OR min_state_value>=0), CHECK(max_state_value IS NULL OR max_state_value>=0), CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value), CHECK( (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) OR application_kind IN ( 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge', 'resource_cost','weapon_reload','marker' ) ) )`); await database.exec(`INSERT INTO conditional_effect_outcomes_weapon_reload ( id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, resource_kind,delivery_mode,delivery_duration_seconds,min_state_value,max_state_value, description_slot,sort_order,created_at,updated_at ) SELECT id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, resource_kind,delivery_mode,delivery_duration_seconds,min_state_value,max_state_value, description_slot,sort_order,created_at,updated_at FROM conditional_effect_outcomes`); await database.exec('DROP TABLE conditional_effect_outcomes'); await database.exec( 'ALTER TABLE conditional_effect_outcomes_weapon_reload RENAME TO conditional_effect_outcomes', ); 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 conditional_effect_outcomes_rule_idx ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); } const outcomeTargetingColumns = await ( await database.prepare("PRAGMA table_info('conditional_effect_outcomes')") ).all(); if ( !outcomeTargetingColumns.some((column) => column.name === 'targeting_json') ) { await database.exec( 'ALTER TABLE conditional_effect_outcomes ADD COLUMN targeting_json TEXT', ); } const outcomeProbabilityColumns = await ( await database.prepare("PRAGMA table_info('conditional_effect_outcomes')") ).all(); if ( !outcomeProbabilityColumns.some( (column) => column.name === 'probability_percent', ) ) { await database.exec( 'ALTER TABLE conditional_effect_outcomes ADD COLUMN probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100))', ); } 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.', ); }