feat: expand ISAC content modeling
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s

This commit is contained in:
jb
2026-08-03 01:23:20 -04:00
parent d9924a95b9
commit 57068b61cd
90 changed files with 9635 additions and 1270 deletions
+327 -3
View File
@@ -91,9 +91,14 @@ const isWeaponSelection = (value) =>
const isChipSelection = (value) =>
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
(value.quality === 'Superior' || value.quality === 'High-end') &&
['Standard', 'Specialized', 'Superior', 'High-end'].includes(value.quality) &&
isRoll(value.primary) &&
isRollPair(value.secondary);
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' &&
@@ -185,7 +190,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
'skillActivations',
'specialization',
]) &&
value.version === 4 &&
value.version === 5 &&
typeof value.buildName === 'string' &&
['maximum', 'custom'].includes(value.calculationMode) &&
hasExactKeys(value.gear, gearSlots) &&
@@ -2392,6 +2397,32 @@ if (
);
}
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();
@@ -2433,6 +2464,7 @@ const iconLibraryCategory = {
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations',
weapon_mod_slot: 'Weapon Mods',
};
const iconLibraryLabel = {
talent: 'Talent',
@@ -2442,6 +2474,17 @@ const iconLibraryLabel = {
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) =>
[
@@ -2532,6 +2575,13 @@ const linkedIconMetadata = async (entityType, entityId) => {
],
};
}
case 'weapon_mod_slot': {
const name = weaponModSlotNames[entityId];
return {
name: name ? `${name} Slot` : null,
tags: ['weapon mod', 'slot', name],
};
}
default:
return { name: null, tags: [] };
}
@@ -2912,6 +2962,267 @@ if (
await database.exec('PRAGMA foreign_keys=ON');
}
for (const kind of ['weapon', 'gear']) {
const optionTable = `${kind}_mod_secondary_options`;
const valueTable = `${kind}_mod_secondary_quality_values`;
const optionColumns = await (
await database.prepare(`PRAGMA table_info('${optionTable}')`)
).all();
if (!optionColumns.some((column) => column.name === `${kind}_mod_id`))
continue;
const conflictingRanges = await (
await database.prepare(`SELECT stat_attribute_id,quality,
COUNT(DISTINCT CAST(min_value AS TEXT)||'/'||CAST(max_value AS TEXT)) AS variants
FROM ${valueTable}
WHERE quality IN ('Specialized','Superior','High-end')
GROUP BY stat_attribute_id,quality HAVING variants>1`)
).all();
if (conflictingRanges.length) {
throw new Error(
`Cannot create the shared ${kind} Mod secondary pool because existing per-Mod ranges disagree. Reconcile ${conflictingRanges.map((row) => `${row.stat_attribute_id}/${row.quality}`).join(', ')} first.`,
);
}
const incompleteOptions = await (
await database.prepare(`SELECT options.stat_attribute_id,
COUNT(DISTINCT CASE WHEN values_row.quality IN ('Specialized','Superior','High-end') THEN values_row.quality END) AS qualities
FROM ${optionTable} options
LEFT JOIN ${valueTable} values_row ON values_row.${kind}_mod_id=options.${kind}_mod_id
AND values_row.stat_attribute_id=options.stat_attribute_id
GROUP BY options.${kind}_mod_id,options.stat_attribute_id HAVING qualities<>3`)
).all();
if (incompleteOptions.length) {
throw new Error(
`Cannot create the shared ${kind} Mod secondary pool because ${incompleteOptions.map((row) => row.stat_attribute_id).join(', ')} does not have all applicable quality ranges.`,
);
}
console.log(
`Converting ${kind} Mod secondary Attributes to one shared level-scaled pool.`,
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(`CREATE TABLE ${optionTable}_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE ${valueTable}_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
level_1_min REAL NOT NULL,
level_1_max REAL NOT NULL,
level_20_min REAL NOT NULL,
level_20_max REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES ${optionTable}_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(level_1_max>=level_1_min),
CHECK(level_20_max>=level_20_min),
CHECK(level_20_min>=level_1_min),
CHECK(level_20_max>=level_1_max)
)`);
await database.exec(`INSERT INTO ${optionTable}_shared(stat_attribute_id)
SELECT DISTINCT stat_attribute_id FROM ${optionTable}`);
await database.exec(`INSERT INTO ${valueTable}_shared(
stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max
) SELECT stat_attribute_id,quality,MIN(min_value),MAX(max_value),MIN(min_value),MAX(max_value)
FROM ${valueTable}
WHERE quality IN ('Specialized','Superior','High-end')
GROUP BY stat_attribute_id,quality`);
await database.exec(`DROP TABLE ${valueTable}`);
await database.exec(`DROP TABLE ${optionTable}`);
await database.exec(
`ALTER TABLE ${optionTable}_shared RENAME TO ${optionTable}`,
);
await database.exec(
`ALTER TABLE ${valueTable}_shared RENAME TO ${valueTable}`,
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const skillChipPrimaryOptionColumns = await (
await database.prepare("PRAGMA table_info('skill_chip_primary_options')")
).all();
if (
skillChipPrimaryOptionColumns.some(
(column) => column.name === 'skill_chip_set_id',
)
) {
const fixedSkillChipAttributes = await (
await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active
FROM stat_attributes
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')`)
).get();
if (
Number(fixedSkillChipAttributes?.total) !== 4 ||
Number(fixedSkillChipAttributes?.active) !== 4
)
throw new Error(
'Skill Chip profile migration requires exactly four active fixed primary Attributes.',
);
console.log(
'Converting Skill Chip primary and secondary options to shared quality profiles.',
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(
'DROP TABLE IF EXISTS skill_chip_primary_quality_values',
);
await database.exec(
'DROP TABLE IF EXISTS skill_chip_secondary_quality_values',
);
await database.exec(`CREATE TABLE skill_chip_primary_options_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE skill_chip_primary_quality_values_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
)`);
await database.exec(`CREATE TABLE skill_chip_secondary_options_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE skill_chip_secondary_quality_values_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
)`);
await database.exec(`INSERT INTO skill_chip_primary_options_shared(stat_attribute_id)
SELECT id FROM stat_attributes
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
AND active=1`);
await database.exec(`INSERT INTO skill_chip_primary_quality_values_shared(
stat_attribute_id,quality,min_value,max_value
) SELECT attributes.id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
FROM stat_attributes attributes
CROSS JOIN (
SELECT 'Standard' quality UNION ALL SELECT 'Specialized'
UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
) qualities
WHERE attributes.name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
AND attributes.active=1
AND attributes.skill_chip_min IS NOT NULL
AND attributes.skill_chip_max IS NOT NULL`);
await database.exec(`INSERT INTO skill_chip_secondary_options_shared(stat_attribute_id)
SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options`);
await database.exec(`INSERT INTO skill_chip_secondary_quality_values_shared(
stat_attribute_id,quality,min_value,max_value
) SELECT options.stat_attribute_id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
FROM (SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options) options
JOIN stat_attributes attributes ON attributes.id=options.stat_attribute_id
CROSS JOIN (
SELECT 'Specialized' quality UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
) qualities
WHERE attributes.skill_chip_min IS NOT NULL
AND attributes.skill_chip_max IS NOT NULL`);
await database.exec('DROP TABLE skill_chip_primary_options');
await database.exec('DROP TABLE skill_chip_secondary_options');
await database.exec(
'ALTER TABLE skill_chip_primary_options_shared RENAME TO skill_chip_primary_options',
);
await database.exec(
'ALTER TABLE skill_chip_primary_quality_values_shared RENAME TO skill_chip_primary_quality_values',
);
await database.exec(
'ALTER TABLE skill_chip_secondary_options_shared RENAME TO skill_chip_secondary_options',
);
await database.exec(
'ALTER TABLE skill_chip_secondary_quality_values_shared RENAME TO skill_chip_secondary_quality_values',
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
await database.exec(`INSERT OR IGNORE INTO skill_chip_primary_options(stat_attribute_id)
SELECT id FROM stat_attributes
WHERE active=1 AND name IN (
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
)`);
const skillChipRangeAttributeColumns = await (
await database.prepare("PRAGMA table_info('stat_attributes')")
).all();
if (
skillChipRangeAttributeColumns.some(
(column) => column.name === 'skill_chip_min',
)
) {
console.log(
'Removing obsolete Attribute-level Skill Chip ranges in favor of shared quality profiles.',
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(
'DROP INDEX IF EXISTS stat_attributes_name_unit_nocase_idx',
);
await database.exec(`CREATE TABLE stat_attributes_current (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL CHECK(type IN ('Offensive','Defensive')),
category TEXT NOT NULL CHECK(category IN ('Weapon','Skill','Character')),
calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive','Multiplicative')),
unit TEXT NOT NULL DEFAULT '',
gear_min REAL,
gear_max REAL,
gear_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50),
weapon_min REAL,
weapon_max REAL,
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
cap_value REAL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min<=gear_max)),
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min<=weapon_max)),
CHECK(cap_value IS NULL OR cap_value>=0)
)`);
await database.exec(`INSERT INTO stat_attributes_current(
id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
cap_value,active,created_at,updated_at
) SELECT id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
cap_value,active,created_at,updated_at FROM stat_attributes`);
await database.exec('DROP TABLE stat_attributes');
await database.exec(
'ALTER TABLE stat_attributes_current RENAME TO stat_attributes',
);
await database.exec(
'CREATE UNIQUE INDEX stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)',
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const buildColumns = await (
await database.prepare("PRAGMA table_info('builds')")
).all();
@@ -3305,6 +3616,19 @@ if (
);
}
const outcomeProbabilityColumns = await (
await database.prepare("PRAGMA table_info('conditional_effect_outcomes')")
).all();
if (
!outcomeProbabilityColumns.some(
(column) => column.name === 'probability_percent',
)
) {
await database.exec(
'ALTER TABLE conditional_effect_outcomes ADD COLUMN probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100))',
);
}
const calibrationCases = await (
await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',