3871 lines
155 KiB
JavaScript
3871 lines
155 KiB
JavaScript
import { connect } from '@tursodatabase/database';
|
|
import { access, mkdir, readFile } from 'node:fs/promises';
|
|
import { dirname, resolve } from 'node:path';
|
|
import process from 'node:process';
|
|
import { 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 isTalentTier = (value) => value === 1 || value === 2;
|
|
const isLegacyGearSelection = (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 isLegacyWeaponSelection = (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 isGearSelection = (value) =>
|
|
hasExactKeys(value, [
|
|
'itemId',
|
|
'level',
|
|
'brandId',
|
|
'talentId',
|
|
'talentTier',
|
|
'health',
|
|
'armor',
|
|
'firepower',
|
|
'toughness',
|
|
'engineering',
|
|
'rolls',
|
|
'augmentSlots',
|
|
]) &&
|
|
['itemId', 'brandId', 'talentId'].every(
|
|
(key) => typeof value[key] === 'string',
|
|
) &&
|
|
isTalentTier(value.talentTier) &&
|
|
['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',
|
|
'talent1Tier',
|
|
'talent2Id',
|
|
'talent2Tier',
|
|
'exoticTalentTier',
|
|
'rolls',
|
|
]) &&
|
|
['itemId', 'talent1Id', 'talent2Id'].every(
|
|
(key) => typeof value[key] === 'string',
|
|
) &&
|
|
isTalentTier(value.talent1Tier) &&
|
|
isTalentTier(value.talent2Tier) &&
|
|
isTalentTier(value.exoticTalentTier) &&
|
|
['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 === 6 &&
|
|
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 isLegacyLoadoutSnapshot = (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) => isLegacyGearSelection(value.gear[slot])) &&
|
|
Array.isArray(value.weapons) &&
|
|
value.weapons.length === 2 &&
|
|
value.weapons.every(isLegacyWeaponSelection) &&
|
|
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 [loadoutGearRows, loadoutWeaponRows, loadoutTalentTierRows] =
|
|
await Promise.all([
|
|
(await database.prepare('SELECT id,tier FROM gear')).all(),
|
|
(await database.prepare('SELECT id,exotic_talent_id FROM weapons')).all(),
|
|
(await database.prepare('SELECT talent_id,tier FROM talent_tiers')).all(),
|
|
]);
|
|
const migrateLegacyLoadoutSnapshot = (raw) => {
|
|
if (!isLegacyLoadoutSnapshot(raw)) return null;
|
|
const value =
|
|
typeof raw === 'string' ? JSON.parse(raw) : structuredClone(raw);
|
|
for (const slot of gearSlots) {
|
|
const item = loadoutGearRows.find(
|
|
(entry) => String(entry.id) === String(value.gear[slot].itemId),
|
|
);
|
|
value.gear[slot].talentTier = Number(item?.tier) >= 2 ? 2 : 1;
|
|
}
|
|
for (const selection of value.weapons) {
|
|
const inheritedTier = Number(selection.tier) >= 2 ? 2 : 1;
|
|
selection.talent1Tier = inheritedTier;
|
|
selection.talent2Tier = inheritedTier;
|
|
const weapon = loadoutWeaponRows.find(
|
|
(entry) => String(entry.id) === String(selection.itemId),
|
|
);
|
|
const exoticTalentId = String(weapon?.exotic_talent_id ?? '');
|
|
const hasExoticTierTwo = loadoutTalentTierRows.some(
|
|
(entry) =>
|
|
String(entry.talent_id) === exoticTalentId && entry.tier === 'II',
|
|
);
|
|
selection.exoticTalentTier =
|
|
inheritedTier === 2 && hasExoticTierTwo ? 2 : 1;
|
|
}
|
|
value.version = 6;
|
|
return isCurrentLoadoutSnapshot(value) ? JSON.stringify(value) : null;
|
|
};
|
|
|
|
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'",
|
|
);
|
|
let builds = await (
|
|
await database.prepare('SELECT id,loadout_json FROM builds')
|
|
).all();
|
|
const migratedBuilds = builds
|
|
.map((build) => ({
|
|
id: String(build.id),
|
|
loadout: migrateLegacyLoadoutSnapshot(build.loadout_json),
|
|
}))
|
|
.filter((build) => build.loadout);
|
|
if (migratedBuilds.length) {
|
|
console.log(
|
|
`Migrating ${migratedBuilds.length} build loadout snapshot(s) to version 6 Talent tiers.`,
|
|
);
|
|
await database.exec('BEGIN');
|
|
try {
|
|
for (const build of migratedBuilds)
|
|
await (
|
|
await database.prepare('UPDATE builds SET loadout_json=? WHERE id=?')
|
|
).run(build.loadout, build.id);
|
|
await database.exec('COMMIT');
|
|
} catch (error) {
|
|
await database.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
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))',
|
|
);
|
|
}
|
|
|
|
let calibrationCases = await (
|
|
await database.prepare(
|
|
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
|
|
)
|
|
).all();
|
|
const migratedCalibrationCases = calibrationCases
|
|
.map((calibrationCase) => ({
|
|
id: String(calibrationCase.id),
|
|
loadout: migrateLegacyLoadoutSnapshot(
|
|
calibrationCase.loadout_snapshot_json,
|
|
),
|
|
}))
|
|
.filter((calibrationCase) => calibrationCase.loadout);
|
|
if (migratedCalibrationCases.length) {
|
|
console.log(
|
|
`Migrating ${migratedCalibrationCases.length} calibration snapshot(s) to version 6 Talent tiers.`,
|
|
);
|
|
await database.exec('BEGIN');
|
|
try {
|
|
for (const calibrationCase of migratedCalibrationCases)
|
|
await (
|
|
await database.prepare(
|
|
'UPDATE calculation_calibration_cases SET loadout_snapshot_json=? WHERE id=?',
|
|
)
|
|
).run(calibrationCase.loadout, calibrationCase.id);
|
|
await database.exec('COMMIT');
|
|
} catch (error) {
|
|
await database.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
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.',
|
|
);
|
|
}
|