From 8b80e55e6ca357fb9710a69aae3599e98dfc1f52 Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 26 Jul 2026 21:01:36 -0400 Subject: [PATCH] refactor: remove retired data format support --- database/schema.sql | 4 +- docs/DEPLOYMENT.md | 9 +- docs/DESIGN_SYSTEM.md | 12 +- docs/architecture.md | 19 +- scripts/db-migrate.mjs | 193 ++++++++++++++++++- scripts/environment-bootstrap-import.mjs | 4 +- scripts/stage-bootstrap-import.mjs | 4 +- src/lib/builder/loadout-combat.ts | 5 - src/lib/builder/loadout.test.ts | 52 +++++ src/lib/builder/loadout.ts | 141 ++++++++------ src/routes/builder/+page.server.ts | 4 +- src/routes/builder/+page.svelte | 36 +--- src/routes/builds/[slug]/+page.server.ts | 8 +- src/routes/isac/calculations/+page.server.ts | 8 +- src/routes/isac/skillchips/+page.server.ts | 31 +-- src/routes/isac/skillchips/+page.svelte | 6 +- src/routes/isac/weaponmods/+page.server.ts | 7 +- 17 files changed, 385 insertions(+), 158 deletions(-) create mode 100644 src/lib/builder/loadout.test.ts diff --git a/database/schema.sql b/database/schema.sql index 5c4a452..d4b30cb 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -123,7 +123,7 @@ CREATE INDEX IF NOT EXISTS oversight_notification_queue_due_idx ON oversight_not CREATE INDEX IF NOT EXISTS oversight_notification_queue_user_category_idx ON oversight_notification_queue(user_id, oversight_category, delivered_at); CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC); CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC); - CREATE TABLE IF NOT EXISTS builds (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(id), loadout_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS builds (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(id), loadout_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility, updated_at DESC); CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL); CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id)); @@ -621,7 +621,7 @@ CREATE TABLE IF NOT EXISTS os_protocol_talent_effects ( CREATE TABLE IF NOT EXISTS skill_chip_sets ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - specialization_ability_id TEXT REFERENCES specialization_abilities(id), + 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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 77c711c..8617635 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -111,7 +111,10 @@ The workflow: 7. waits for `/api/health/database` to report healthy; 8. retains the logs and exits unsuccessfully if health verification fails. -The outage covers backup, migration, and container startup. +The outage covers backup, migration, and container startup. Migrations may also +remove records that use retired data formats when a release explicitly ends +compatibility. The deployment backup remains the recovery source for those +records; the application does not repair or reinterpret them at runtime. The scheduler calls a bearer-authenticated endpoint on the application service once per minute. Scheduled message delivery therefore uses the web process's existing embedded-database connection instead of opening the locked database @@ -123,7 +126,9 @@ consolidation, and email digest checks and is not published through a host port. Initialize the first staging release from the development database, including users, roles, builds, guides, proposals, audit history, avatars, and uploaded game-data icons. The staging importer refuses a target database that contains -users, builds, audits, or proposals. +users, builds, audits, or proposals. It also requires the source and target +table sets to match exactly; snapshots with retired or unknown tables are +rejected. First stop this project's development server. The embedded database must not be open while its complete transfer bundle is created: diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index b65edb4..1f22efa 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1700,10 +1700,9 @@ Exclusive child-record relationships use a searchable assignment block directly after the record identity. The header names the parent type, states the relationship and capacity in plain language, and shows current usage as `N / N`. The registry repeats the assigned parent beside each child so exclusivity can be -audited without opening the editor. Legacy records may remain visibly -`UNASSIGNED`, but creation, restoration, and successful edits require a valid -active parent. Capacity rules are enforced on the server and never communicated -by color alone. +audited without opening the editor. Records that require an exclusive parent +must always have a valid active assignment. Capacity rules are enforced on the +server and never communicated by color alone. Rank cards present the complete rank description, structured effects, point cost, Credits, and material cost together. Specialized Alpha, Beta, and Gamma @@ -1762,8 +1761,9 @@ user buys every available Rank 1 and Rank 2 in preceding rows and enforces the 49-point limit. Cost telemetry lists Credits and components beside each rank. Saved loadouts store focus, node ranks, nine Skill Chips, effect modes, and -combat assumptions. Version 1 loadouts keep their three legacy chips in an -unassigned recovery list until the user places them under compatible abilities. +combat assumptions. The builder accepts only the current complete structure. +Invalid local drafts are discarded; invalid server records are unavailable and +removed through a backed-up database migration. ### Calculation revision controls diff --git a/docs/architecture.md b/docs/architecture.md index dc889d5..5eff4ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -275,11 +275,11 @@ distinct secondary stats from those separate pools. Every active Skill Chip definition belongs exclusively to one active specialization ability through `skill_chip_sets.specialization_ability_id`. An ability has capacity for exactly three active definitions. The save and -restore actions enforce that capacity, while legacy rows may remain nullable -until they are edited. A specialization focus is builder-ready only when each -of its three assigned abilities has all three active Skill Chip definitions. -Skill Chip mutations refresh readiness for focuses that reference both the -previous and resulting ability. +restore actions enforce that capacity, and the foreign key is required for +every row. A specialization focus is builder-ready only when each of its three +assigned abilities has all three active Skill Chip definitions. Skill Chip +mutations refresh readiness for focuses that reference both the previous and +resulting ability. Both set-bonus progression systems use the number of equipped High-end chips as the interpolation step. For `h` High-end chips, where `h` is zero through @@ -308,10 +308,11 @@ Specialization proposal workflow. Loadout schema version 2 stores one focus, the selected rank for each tree node, nine Skill Chips, conditional-effect modes, and encounter assumptions. Each of -the three focus abilities owns three chip positions. Version 1 migration keeps -the three old chips in `specialization.unassignedChips`; the builder requires -the user to place them under compatible abilities before it reports a complete -calculation. +the three focus abilities owns three chip positions. Runtime readers accept +only the exact version 2 structure. Local drafts, saved builds, shared builds, +and calibration snapshots reject older or partial structures instead of +repairing them. Database migration removes obsolete snapshots after the +required environment backup. # Versioned combat calculations diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 2da21c1..a0f7e93 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -4,6 +4,80 @@ import { dirname, resolve } from 'node:path'; import process from 'node:process'; import { initializeDefaultUsers } from './default-users.mjs'; +const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; +const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const hasExactKeys = (value, keys) => + isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value); +const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value); +const isRoll = (value) => + hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value); +const isRollPair = (value) => Array.isArray(value) && value.length === 2 && value.every(isRoll); +const isGearSelection = (value) => + hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots']) + && ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string') + && ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key])) + && isRollPair(value.rolls) + && Array.isArray(value.augmentSlots) + && value.augmentSlots.every(isFiniteNumber); +const isWeaponSelection = (value) => + hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls']) + && ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string') + && ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key])) + && isRollPair(value.rolls); +const isChipSelection = (value) => + hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) + && typeof value.setId === 'string' + && (value.quality === 'Superior' || value.quality === 'High-end') + && isRoll(value.primary) + && isRollPair(value.secondary); +const isProtocolSelection = (value) => + hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) + && typeof value.itemId === 'string' + && isFiniteNumber(value.level) + && isFiniteNumber(value.talentUpgrades); +const combatProfileKeys = ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity']; +const isCombatProfile = (value) => + hasExactKeys(value, combatProfileKeys) && combatProfileKeys.every((key) => isFiniteNumber(value[key])); +const isEffectStates = (value) => + isRecord(value) && Object.values(value).every((entry) => { + if (!hasExactKeys(entry, entry?.overrides === undefined ? ['mode'] : ['mode', 'overrides']) + || !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false; + return entry.overrides === undefined || ( + isRecord(entry.overrides) + && Object.entries(entry.overrides).every(([key, override]) => + combatProfileKeys.includes(key) && isFiniteNumber(override) + ) + ); + }); +const isCurrentLoadoutSnapshot = (raw) => { + let value; + try { + value = typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch { + return false; + } + return hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization']) + && value.version === 2 + && typeof value.buildName === 'string' + && hasExactKeys(value.gear, gearSlots) + && gearSlots.every((slot) => isGearSelection(value.gear[slot])) + && Array.isArray(value.weapons) + && value.weapons.length === 2 + && value.weapons.every(isWeaponSelection) + && Array.isArray(value.chips) + && value.chips.length === 9 + && value.chips.every(isChipSelection) + && isProtocolSelection(value.protocol) + && Array.isArray(value.activeTalents) + && value.activeTalents.every((talent) => typeof talent === 'string') + && hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile']) + && typeof value.specialization.focusId === 'string' + && isRecord(value.specialization.nodeRanks) + && Object.values(value.specialization.nodeRanks).every(isFiniteNumber) + && isEffectStates(value.specialization.effectStates) + && isCombatProfile(value.specialization.combatProfile); +}; + const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'); const schemaPath = resolve('./database/schema.sql'); let databaseAlreadyExisted = true; @@ -106,15 +180,7 @@ const legacyProtocolEffects = await (await database.prepare( "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='os_protocol_attribute_effects'" )).get(); if (legacyProtocolEffects) { - const legacyProtocolEffectCount = await (await database.prepare( - 'SELECT COUNT(*) AS count FROM os_protocol_attribute_effects' - )).get(); - if (Number(legacyProtocolEffectCount?.count ?? 0) > 0) { - throw new Error( - 'Legacy OS Protocol attribute effects still contain rows. Export and reconcile them before removing the retired table.' - ); - } - console.log('Removing the empty legacy OS Protocol attribute-effects table.'); + console.log('Removing the retired OS Protocol attribute-effects table.'); await database.exec('DROP TABLE os_protocol_attribute_effects'); } @@ -621,6 +687,58 @@ if (buildColumns.length && !buildColumns.some((column) => column.name === 'loado } if (buildColumns.length) { await database.exec("UPDATE builds SET visibility='private' WHERE visibility='draft'"); + const builds = await (await database.prepare('SELECT id,loadout_json FROM builds')).all(); + const obsoleteBuildIds = builds + .filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json)) + .map((build) => String(build.id)); + if (obsoleteBuildIds.length) { + console.log(`Removing ${obsoleteBuildIds.length} build record(s) that do not use the current loadout format.`); + await database.exec('BEGIN'); + try { + for (const id of obsoleteBuildIds) { + await (await database.prepare('UPDATE builds SET source_build_id=NULL WHERE source_build_id=?')).run(id); + await (await database.prepare('DELETE FROM builds WHERE id=?')).run(id); + } + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } + } + const currentBuildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all(); + const loadoutColumn = currentBuildColumns.find((column) => column.name === 'loadout_json'); + if (loadoutColumn && Number(loadoutColumn.notnull) !== 1) { + console.log('Requiring every build record to contain a current loadout snapshot.'); + await database.exec('DROP INDEX IF EXISTS builds_visibility_updated_idx'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP TABLE IF EXISTS builds_current'); + await database.exec(`CREATE TABLE builds_current ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + visibility TEXT NOT NULL DEFAULT 'private', + summary TEXT, + guide_body TEXT, + source_build_id TEXT REFERENCES builds_current(id), + loadout_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`); + await database.exec(`INSERT INTO builds_current(id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at) + SELECT id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at FROM builds`); + await database.exec('DROP TABLE builds'); + await database.exec('ALTER TABLE builds_current RENAME TO builds'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + } } await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)'); @@ -629,7 +747,64 @@ if (skillChipColumns.length && !skillChipColumns.some((column) => column.name == console.log('Associating Skill Chip definitions with specialization abilities.'); await database.exec('ALTER TABLE skill_chip_sets ADD COLUMN specialization_ability_id TEXT REFERENCES specialization_abilities(id)'); } +const currentSkillChipColumns = await (await database.prepare("PRAGMA table_info('skill_chip_sets')")).all(); +const skillChipAbilityColumn = currentSkillChipColumns.find((column) => column.name === 'specialization_ability_id'); +if (skillChipAbilityColumn && Number(skillChipAbilityColumn.notnull) !== 1) { + const unassignedSkillChips = await (await database.prepare( + 'SELECT id FROM skill_chip_sets WHERE specialization_ability_id IS NULL' + )).all(); + if (unassignedSkillChips.length) { + console.log(`Removing ${unassignedSkillChips.length} Skill Chip record(s) without a specialization ability.`); + for (const row of unassignedSkillChips) { + await (await database.prepare('DELETE FROM skill_chip_sets WHERE id=?')).run(row.id); + } + } + console.log('Requiring every Skill Chip definition to reference a specialization ability.'); + await database.exec('DROP INDEX IF EXISTS skill_chip_sets_ability_idx'); + await database.exec('DROP INDEX IF EXISTS skill_chip_sets_name_nocase_idx'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP TABLE IF EXISTS skill_chip_sets_current'); + await database.exec(`CREATE TABLE skill_chip_sets_current ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + specialization_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`); + await database.exec(`INSERT INTO skill_chip_sets_current(id,name,specialization_ability_id,active,created_at,updated_at) + SELECT id,name,specialization_ability_id,active,created_at,updated_at FROM skill_chip_sets`); + await database.exec('DROP TABLE skill_chip_sets'); + await database.exec('ALTER TABLE skill_chip_sets_current RENAME TO skill_chip_sets'); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } +} +await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE)'); await database.exec('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active)'); + +const calibrationCases = await (await database.prepare( + 'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases' +)).all(); +const obsoleteCalibrationIds = calibrationCases + .filter((calibrationCase) => !isCurrentLoadoutSnapshot(calibrationCase.loadout_snapshot_json)) + .map((calibrationCase) => String(calibrationCase.id)); +if (obsoleteCalibrationIds.length) { + console.log(`Removing ${obsoleteCalibrationIds.length} calculation calibration case(s) with obsolete loadout snapshots.`); + for (const id of obsoleteCalibrationIds) { + await (await database.prepare('DELETE FROM calculation_calibration_cases WHERE id=?')).run(id); + } +} +const foreignKeyFailures = await (await database.prepare('PRAGMA foreign_key_check')).all(); +if (foreignKeyFailures.length) { + throw new Error(`Schema migration left ${foreignKeyFailures.length} foreign-key violation(s).`); +} await database.exec(`UPDATE specialization_focuses SET builder_ready=0,updated_at='${new Date().toISOString()}' WHERE builder_ready=1 AND ( SELECT COUNT(*) FROM specialization_focus_abilities link diff --git a/scripts/environment-bootstrap-import.mjs b/scripts/environment-bootstrap-import.mjs index 962fe89..ceabafe 100644 --- a/scripts/environment-bootstrap-import.mjs +++ b/scripts/environment-bootstrap-import.mjs @@ -70,7 +70,7 @@ const sourceOnlyTables = bundle.database.tables .map((table) => String(table.name)) .filter((name) => !targetNames.has(name)); if (sourceOnlyTables.length) { - console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`); + throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`); } const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; let restoredRows = 0; @@ -80,7 +80,7 @@ try { for (const row of [...targetTables].reverse()) { await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); } - for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) { + for (const table of bundle.database.tables) { for (const row of table.rows) { const columns = Object.keys(row); if (!columns.length) continue; diff --git a/scripts/stage-bootstrap-import.mjs b/scripts/stage-bootstrap-import.mjs index bb79c62..5038b63 100644 --- a/scripts/stage-bootstrap-import.mjs +++ b/scripts/stage-bootstrap-import.mjs @@ -64,7 +64,7 @@ const sourceOnlyTables = bundle.database.tables .map((table) => String(table.name)) .filter((name) => !targetNames.has(name)); if (sourceOnlyTables.length) { - console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`); + throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`); } const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; let restoredRows = 0; @@ -74,7 +74,7 @@ try { for (const row of [...targetTables].reverse()) { await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); } - for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) { + for (const table of bundle.database.tables) { for (const row of table.rows) { const columns = Object.keys(row); if (!columns.length) continue; diff --git a/src/lib/builder/loadout-combat.ts b/src/lib/builder/loadout-combat.ts index 671da9d..6cc1fff 100644 --- a/src/lib/builder/loadout-combat.ts +++ b/src/lib/builder/loadout-combat.ts @@ -318,11 +318,6 @@ export function evaluateLoadoutCombat( else if ((focus?.abilities.length ?? 0) !== 3) missing.push('Three configured focus abilities'); const missingChips = draft.chips.filter((chip) => !chip.setId).length; if (missingChips) missing.push(`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`); - if (draft.specialization.unassignedChips.length) { - missing.push( - `${draft.specialization.unassignedChips.length} unresolved legacy Skill Chip${draft.specialization.unassignedChips.length === 1 ? '' : 's'}` - ); - } return missing; }; diff --git a/src/lib/builder/loadout.test.ts b/src/lib/builder/loadout.test.ts new file mode 100644 index 0000000..f1486aa --- /dev/null +++ b/src/lib/builder/loadout.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { createDraft, parseLoadoutDraft } from './loadout.js'; + +const copyDraft = () => structuredClone(createDraft()); + +describe('parseLoadoutDraft', () => { + it('accepts a current draft and returns an independent clone', () => { + const draft = createDraft(); + const parsed = parseLoadoutDraft(draft); + + expect(parsed).toEqual(draft); + expect(parsed).not.toBe(draft); + parsed!.gear.Holster.level = 1; + expect(draft.gear.Holster.level).toBe(50); + }); + + it('rejects the retired version 1 three-chip shape', () => { + const retired = copyDraft() as unknown as Record; + retired.version = 1; + retired.chips = (retired.chips as unknown[]).slice(0, 3); + + expect(parseLoadoutDraft(retired)).toBeNull(); + }); + + it('rejects a version 2 draft that does not contain exactly nine chips', () => { + const draft = copyDraft(); + draft.chips.pop(); + + expect(parseLoadoutDraft(draft)).toBeNull(); + }); + + it('rejects a specialization missing a required current field', () => { + const draft = copyDraft() as unknown as { specialization: Record }; + delete draft.specialization.combatProfile; + + expect(parseLoadoutDraft(draft)).toBeNull(); + }); + + it('rejects a specialization that carries retired unassigned chips', () => { + const draft = copyDraft() as unknown as { specialization: Record }; + draft.specialization.unassignedChips = []; + + expect(parseLoadoutDraft(draft)).toBeNull(); + }); + + it('rejects malformed nested equipment selections', () => { + const draft = copyDraft(); + draft.weapons[0].rolls[1] = { statId: 'weapon-damage', value: Number.NaN }; + + expect(parseLoadoutDraft(draft)).toBeNull(); + }); +}); diff --git a/src/lib/builder/loadout.ts b/src/lib/builder/loadout.ts index 303db5c..054d80a 100644 --- a/src/lib/builder/loadout.ts +++ b/src/lib/builder/loadout.ts @@ -11,7 +11,6 @@ export type SpecializationSelection = { nodeRanks: Record; effectStates: Record; combatProfile: CombatProfile; - unassignedChips: ChipSelection[]; }; export type LoadoutDraft = { version: 2; @@ -24,11 +23,6 @@ export type LoadoutDraft = { specialization: SpecializationSelection; }; -type LegacyLoadoutDraft = Omit & { - version: 1; - chips: [ChipSelection, ChipSelection, ChipSelection]; -}; - export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const; export const blankRoll = (): Roll => ({ statId: '', value: 0 }); export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, firepower: 0, toughness: 0, engineering: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] }); @@ -47,66 +41,87 @@ export const createDraft = (): LoadoutDraft => ({ focusId: '', nodeRanks: {}, effectStates: {}, - combatProfile: defaultCombatProfile(), - unassignedChips: [] + combatProfile: defaultCombatProfile() } }); -export function migrateLoadoutDraft(value: unknown): LoadoutDraft | null { - if (!value || typeof value !== 'object') return null; - const draft = value as { - version?: number; - buildName?: string; - gear?: Record; - weapons?: WeaponSelection[]; - chips?: ChipSelection[]; - protocol?: ProtocolSelection; - activeTalents?: string[]; - specialization?: SpecializationSelection; - }; - if (typeof draft.buildName !== 'string' || !draft.gear || !Array.isArray(draft.weapons) - || draft.weapons.length !== 2 || !draft.protocol || !Array.isArray(draft.chips)) return null; - if (draft.version === 1 && draft.chips.length === 3) { - const migrated = createDraft(); - return { - ...migrated, - ...draft, - version: 2, - gear: draft.gear, - weapons: draft.weapons as [WeaponSelection, WeaponSelection], - chips: migrated.chips, - protocol: draft.protocol, - activeTalents: draft.activeTalents ?? [], - specialization: { - ...migrated.specialization, - unassignedChips: draft.chips.map((chip) => structuredClone(chip)) - } - }; - } - if (draft.version !== 2) return null; - const fresh = createDraft(); - const chips = [...draft.chips.slice(0, 9)]; - while (chips.length < 9) chips.push(blankChip()); - return { - ...fresh, - ...draft, - version: 2, - gear: draft.gear, - weapons: draft.weapons as [WeaponSelection, WeaponSelection], - chips: chips as LoadoutDraft['chips'], - protocol: draft.protocol, - activeTalents: draft.activeTalents ?? [], - specialization: { - ...fresh.specialization, - ...(draft.specialization ?? {}), - nodeRanks: { ...(draft.specialization?.nodeRanks ?? {}) }, - effectStates: { ...(draft.specialization?.effectStates ?? {}) }, - combatProfile: { ...fresh.specialization.combatProfile, ...(draft.specialization?.combatProfile ?? {}) }, - unassignedChips: Array.isArray(draft.specialization?.unassignedChips) - ? draft.specialization.unassignedChips - : [] - } - }; +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const hasExactKeys = (value: Record, keys: string[]) => + Object.keys(value).length === keys.length && keys.every((key) => key in value); +const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); +const isNumberRecord = (value: unknown) => + isRecord(value) && Object.values(value).every((entry) => typeof entry === 'number' && Number.isFinite(entry)); +const isRoll = (value: unknown): value is Roll => + isRecord(value) + && hasExactKeys(value, ['statId', 'value']) + && typeof value.statId === 'string' + && isFiniteNumber(value.value); +const isRollPair = (value: unknown): value is [Roll, Roll] => + Array.isArray(value) && value.length === 2 && value.every(isRoll); +const isGearSelection = (value: unknown): value is GearSelection => + isRecord(value) + && hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots']) + && ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string') + && ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key])) + && isRollPair(value.rolls) + && Array.isArray(value.augmentSlots) + && value.augmentSlots.every(isFiniteNumber); +const isWeaponSelection = (value: unknown): value is WeaponSelection => + isRecord(value) + && hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls']) + && ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string') + && ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key])) + && isRollPair(value.rolls); +const isChipSelection = (value: unknown): value is ChipSelection => + isRecord(value) + && hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) + && typeof value.setId === 'string' + && (value.quality === 'Superior' || value.quality === 'High-end') + && isRoll(value.primary) + && isRollPair(value.secondary); +const isProtocolSelection = (value: unknown): value is ProtocolSelection => + isRecord(value) + && hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) + && typeof value.itemId === 'string' + && isFiniteNumber(value.level) + && isFiniteNumber(value.talentUpgrades); +const isCombatProfile = (value: unknown): value is CombatProfile => + isRecord(value) + && hasExactKeys(value, ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity']) + && ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].every((key) => isFiniteNumber(value[key])); +const isEffectStates = (value: unknown): value is Record => + isRecord(value) && Object.values(value).every((entry) => { + if (!isRecord(entry) + || !hasExactKeys(entry, entry.overrides === undefined ? ['mode'] : ['mode', 'overrides']) + || !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false; + return entry.overrides === undefined || ( + isRecord(entry.overrides) + && Object.entries(entry.overrides).every(([key, override]) => + ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].includes(key) && isFiniteNumber(override) + ) + ); + }); + +export function parseLoadoutDraft(value: unknown): LoadoutDraft | null { + if (!isRecord(value) + || !hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization']) + || value.version !== 2 + || typeof value.buildName !== 'string') return null; + const gear = value.gear; + if (!isRecord(gear) || Object.keys(gear).length !== gearSlots.length + || !gearSlots.every((slot) => isGearSelection(gear[slot]))) return null; + if (!Array.isArray(value.weapons) || value.weapons.length !== 2 || !value.weapons.every(isWeaponSelection)) return null; + if (!Array.isArray(value.chips) || value.chips.length !== 9 || !value.chips.every(isChipSelection)) return null; + if (!isProtocolSelection(value.protocol) || !Array.isArray(value.activeTalents) + || !value.activeTalents.every((talent) => typeof talent === 'string')) return null; + if (!isRecord(value.specialization) + || !hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile']) + || typeof value.specialization.focusId !== 'string' + || !isNumberRecord(value.specialization.nodeRanks) + || !isEffectStates(value.specialization.effectStates) + || !isCombatProfile(value.specialization.combatProfile)) return null; + return structuredClone(value) as LoadoutDraft; } export const clamp = (value: unknown, min: unknown, max: unknown) => { diff --git a/src/routes/builder/+page.server.ts b/src/routes/builder/+page.server.ts index 51af95c..6575e88 100644 --- a/src/routes/builder/+page.server.ts +++ b/src/routes/builder/+page.server.ts @@ -3,7 +3,7 @@ import { gameDataIconUrl } from '$lib/server/game-data-icons'; import { getSessionUser } from '$lib/server/auth/session'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; -import { migrateLoadoutDraft } from '$lib/builder/loadout'; +import { parseLoadoutDraft } from '$lib/builder/loadout'; import type { Actions, PageServerLoad } from './$types'; const validVisibility = new Set(['private', 'unlisted', 'public']); @@ -13,7 +13,7 @@ const slugify = (value: string) => value.toLowerCase().normalize('NFKD') function parseLoadout(raw: FormDataEntryValue | null) { if (typeof raw !== 'string' || raw.length > 250_000) return null; try { - return migrateLoadoutDraft(JSON.parse(raw)); + return parseLoadoutDraft(JSON.parse(raw)); } catch { return null; } diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index c9f3e4e..ede9720 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -14,12 +14,12 @@ import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat'; import { toast } from '$lib/toast'; import { - blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraft, remapRangeValue, scaled, weaponBaseRange, + blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange, type LoadoutDraft, type Roll } from '$lib/builder/loadout'; let { data, form } = $props(); - let draft = $state(untrack(() => migrateLoadoutDraft(data.initialBuild?.loadout) ?? createDraft())); + let draft = $state(untrack(() => parseLoadoutDraft(data.initialBuild?.loadout) ?? createDraft())); let active = $state('gear:Holster'); let saved = $state(false); let message = $state(''); @@ -138,32 +138,29 @@ const selectFocus = (focusId: string) => { const next = allFocuses.find((focus: any) => focus.id === focusId); if (!next) return; - const unassigned = [...draft.specialization.unassignedChips]; + const displaced: LoadoutDraft['chips'][number][] = []; for (let index = 0; index < draft.chips.length; index += 1) { const chip = draft.chips[index]; const ability = next.abilities[Math.floor(index / 3)]; const definition = data.chipSets.find((item: any) => item.id === chip.setId); if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) { - unassigned.push(structuredClone(chip)); + displaced.push(structuredClone(chip)); draft.chips[index] = createDraft().chips[index]; } } - for (let index = unassigned.length - 1; index >= 0; index -= 1) { - const chip = unassigned[index]; + for (const chip of displaced) { const definition = data.chipSets.find((item: any) => item.id === chip.setId); const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id); if (abilityIndex < 0) continue; const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId); if (targetIndex === undefined) continue; draft.chips[targetIndex] = structuredClone(chip); - unassigned.splice(index, 1); } draft.specialization = { ...draft.specialization, focusId, nodeRanks: {}, - effectStates: {}, - unassignedChips: unassigned + effectStates: {} }; active = 'specialization:focus'; }; @@ -304,28 +301,17 @@ }); onMount(() => { if (data.initialBuild) return; - const stored = localStorage.getItem('shd-build-loadout-draft-v2') ?? localStorage.getItem('shd-build-loadout-draft-v1'); + const stored = localStorage.getItem('shd-build-loadout-draft-v2'); if (!stored) return; try { - const parsed = migrateLoadoutDraft(JSON.parse(stored)); + const parsed = parseLoadoutDraft(JSON.parse(stored)); if (parsed) { draft = parsed; - for (const slot of gearSlots) { - if (!Number.isInteger(draft.gear[slot].level)) draft.gear[slot].level = 50; - const item = gearItem(slot), core = coreAllocationRange(Number(item?.tier ?? 0), draft.gear[slot].level, 'gear'); - for (const key of coreKeys) if (!Number.isFinite(draft.gear[slot][key])) draft.gear[slot][key] = core.min; - draft.gear[slot].rolls = [...(draft.gear[slot].rolls ?? []).slice(0, 2), blankRoll(), blankRoll()].slice(0, 2) as [Roll, Roll]; - } - for (const weapon of draft.weapons) { - if (!Number.isInteger(weapon.level)) weapon.level = 50; - weapon.rolls = [...(weapon.rolls ?? []).slice(0, 2), blankRoll(), blankRoll()].slice(0, 2) as [Roll, Roll]; - } if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = ''; saved = true; - } + } else localStorage.removeItem('shd-build-loadout-draft-v2'); } catch { localStorage.removeItem('shd-build-loadout-draft-v2'); - localStorage.removeItem('shd-build-loadout-draft-v1'); } }); $effect(() => { @@ -342,7 +328,6 @@ }); function saveDraft() { localStorage.setItem('shd-build-loadout-draft-v2', JSON.stringify(draft)); - localStorage.removeItem('shd-build-loadout-draft-v1'); saved = true; toast.success('Draft saved locally', 'This loadout is available on this device.'); } @@ -397,9 +382,6 @@ {#if activeParts[0] === 'specialization'}
AGENT CLASS

SPECIALIZATION FOCUS

{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}
- {#if draft.specialization.unassignedChips.length} -
UNRESOLVED LEGACY SKILL CHIPS{draft.specialization.unassignedChips.length} chip selection{draft.specialization.unassignedChips.length === 1 ? '' : 's'} from the previous builder could not be matched to this focus. The original data remains in this draft.
- {/if} {#if selectedFocus}
{selectedFocus.specialization.name}

{selectedFocus.name}

{selectedFocus.description || 'No focus description has been entered.'}

{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}
diff --git a/src/routes/builds/[slug]/+page.server.ts b/src/routes/builds/[slug]/+page.server.ts index e907f11..4728f0a 100644 --- a/src/routes/builds/[slug]/+page.server.ts +++ b/src/routes/builds/[slug]/+page.server.ts @@ -1,7 +1,7 @@ import { error } from '@sveltejs/kit'; import { getDatabase } from '$lib/server/database'; import { getSessionUser } from '$lib/server/auth/session'; -import { migrateLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout'; +import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout'; import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat'; import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat'; import type { PageServerLoad } from './$types'; @@ -19,9 +19,9 @@ export const load: PageServerLoad = async ({ params, cookies }) => { if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found'); let loadout: LoadoutDraft; try { - const migrated = migrateLoadoutDraft(JSON.parse(String(build.loadout_json))); - if (!migrated) error(404, 'Build data is unavailable'); - loadout = migrated; + const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json))); + if (!parsed) error(404, 'Build data is unavailable'); + loadout = parsed; } catch { error(404, 'Build data is unavailable'); } diff --git a/src/routes/isac/calculations/+page.server.ts b/src/routes/isac/calculations/+page.server.ts index 217342f..df6aceb 100644 --- a/src/routes/isac/calculations/+page.server.ts +++ b/src/routes/isac/calculations/+page.server.ts @@ -6,7 +6,7 @@ import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { recordIsacAudit } from '$lib/server/audit'; import { getPagination, getRegistrySearch } from '$lib/server/pagination'; -import { migrateLoadoutDraft } from '$lib/builder/loadout'; +import { parseLoadoutDraft } from '$lib/builder/loadout'; const operators = new Set([ 'base_damage', 'flat_add', 'additive_percent', 'multiplicative_percent', @@ -250,8 +250,8 @@ export const actions: Actions = { const snapshotText = String(form.get('loadoutSnapshot') ?? '').trim(); let snapshot: unknown; try { snapshot = JSON.parse(snapshotText); } catch { return fail(400, { calibrationError: 'The loadout snapshot is not valid JSON.' }); } - const migrated = migrateLoadoutDraft(snapshot); - if (!migrated) return fail(400, { calibrationError: 'The loadout snapshot is not a recognized SHD.build loadout.' }); + const parsedLoadout = parseLoadoutDraft(snapshot); + if (!parsedLoadout) return fail(400, { calibrationError: 'The loadout snapshot does not match the current SHD.build loadout format.' }); const observed = { bodyHit: optionalNumber(form.get('bodyHit')), criticalBodyHit: optionalNumber(form.get('criticalBodyHit')), @@ -279,7 +279,7 @@ export const actions: Actions = { if (id && !before) return fail(404, { calibrationError: 'That calibration case no longer exists.' }); const now = new Date().toISOString(); const values = [ - name, status, JSON.stringify(migrated), Number(form.get('selectedWeapon') ?? 0), + name, status, JSON.stringify(parsedLoadout), Number(form.get('selectedWeapon') ?? 0), clean(form.get('gameVersion')) || null, clean(form.get('observedAt')) || null, clean(form.get('notes')).slice(0, 4000) || null, observed.bodyHit, observed.criticalBodyHit, observed.headshot, observed.criticalHeadshot, diff --git a/src/routes/isac/skillchips/+page.server.ts b/src/routes/isac/skillchips/+page.server.ts index f912241..0f4fd0a 100644 --- a/src/routes/isac/skillchips/+page.server.ts +++ b/src/routes/isac/skillchips/+page.server.ts @@ -46,12 +46,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => { const user = await requireDataView(await getSessionUser(cookies)); const database = await getDatabase(); const requestedFilter = url.searchParams.get('filter') ?? ''; - const filter = ['active', 'archived', 'configured', 'assigned', 'unassigned'].includes(requestedFilter) ? requestedFilter : ''; + const filter = ['active', 'archived', 'configured', 'assigned'].includes(requestedFilter) ? requestedFilter : ''; const { search, pattern } = getRegistrySearch(url); const conditions = [ ...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0'] : filter === 'assigned' ? ['sets.specialization_ability_id IS NOT NULL'] - : filter === 'unassigned' ? ['sets.specialization_ability_id IS NULL'] : filter === 'configured' ? ['EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)', 'EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)'] : []), ...(search ? ["sets.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []) ]; @@ -62,7 +61,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => { SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived, SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned, - SUM(CASE WHEN specialization_ability_id IS NULL THEN 1 ELSE 0 END) AS unassigned, SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured FROM skill_chip_sets`)).get(), (await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(...args), @@ -111,9 +109,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => { chipCount: Number(abilityChipCounts.find((item) => item.specialization_ability_id === ability.id)?.count ?? 0) })) ]; - const pendingSetRows = filter === 'archived' ? [] : pendingSets + const currentPendingSets = pendingSets.filter((set) => + Boolean(set.abilityId) + && Array.isArray(set.primaryStatIds) + && Array.isArray(set.secondaryStatIds) + && Array.isArray(set.triggerIds) + ); + const pendingSetRows = filter === 'archived' ? [] : currentPendingSets .filter((set) => filter !== 'assigned' || Boolean(set.abilityId)) - .filter((set) => filter !== 'unassigned' || !set.abilityId) .filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())) .map((set) => ({ ...set, @@ -124,13 +127,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => { min_value: Number(set.twoPieceMin), max_value: Number(set.twoPieceMax) }, - primaryStatIds: Array.isArray(set.primaryStatIds) ? set.primaryStatIds : [set.primaryStatIds].filter(Boolean), - secondaryStatIds: Array.isArray(set.secondaryStatIds) ? set.secondaryStatIds : [set.secondaryStatIds].filter(Boolean), + primaryStatIds: set.primaryStatIds, + secondaryStatIds: set.secondaryStatIds, talent: { name: set.talentName, description_template: set.descriptionTemplate, effects: (() => { try { return JSON.parse(String(set.talentEffects ?? '[]')); } catch { return []; } })(), - triggerIds: Array.isArray(set.triggerIds) ? set.triggerIds : [set.triggerIds].filter(Boolean) + triggerIds: set.triggerIds } })); return { @@ -158,10 +161,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => { active: Number(summary?.active ?? 0), archived: Number(summary?.archived ?? 0), configured: Number(summary?.configured ?? 0), - total: Number(summary?.total ?? 0) + pendingSets.length, - assigned: Number(summary?.assigned ?? 0) + pendingSets.filter((set) => Boolean(set.abilityId)).length, - unassigned: Number(summary?.unassigned ?? 0) + pendingSets.filter((set) => !set.abilityId).length, - pending: pendingSets.length + total: Number(summary?.total ?? 0) + currentPendingSets.length, + assigned: Number(summary?.assigned ?? 0) + currentPendingSets.length, + pending: currentPendingSets.length }, filter, pagination, search }; @@ -251,7 +253,7 @@ export const actions: Actions = { if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' }); const database = await getDatabase(); await (await database.prepare('UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); - await refreshAbilityFocuses(database, String(before.record.specialization_ability_id ?? '')); + await refreshAbilityFocuses(database, String(before.record.specialization_ability_id)); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.archived', entityId: id, before, after: await snapshot(id) }); return { skillChipArchived: true }; }, @@ -260,8 +262,7 @@ export const actions: Actions = { const id = String((await request.formData()).get('id') ?? ''); const before = await snapshot(id); if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' }); - const abilityId = String(before.record.specialization_ability_id ?? ''); - if (!abilityId) return fail(400, { skillChipError: 'Assign this legacy Skill Chip to an ability before restoring it.' }); + const abilityId = String(before.record.specialization_ability_id); const database = await getDatabase(); if (!await (await database.prepare('SELECT id FROM specialization_abilities WHERE id=? AND active=1')).get(abilityId)) { return fail(400, { skillChipError: 'The assigned specialization ability must be active before restoring this Skill Chip.' }); diff --git a/src/routes/isac/skillchips/+page.svelte b/src/routes/isac/skillchips/+page.svelte index 3e26035..273e9f1 100644 --- a/src/routes/isac/skillchips/+page.svelte +++ b/src/routes/isac/skillchips/+page.svelte @@ -38,7 +38,7 @@ const edit = (item: any) => { editing = { ...item, - abilityId: item.specialization_ability_id ?? '', + abilityId: String(item.specialization_ability_id), twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [...item.primaryStatIds], secondaryStatIds: [...item.secondaryStatIds], @@ -69,7 +69,7 @@ {value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'}, {value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'}, {value:'assigned',label:'ABILITY LINKED',count:data.summary.assigned,description:'Assigned to a class ability'}, - {value:'unassigned',label:'UNASSIGNED',count:data.summary.unassigned,description:'Legacy records needing an ability'}, + {value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Complete 2-piece and 3-piece effects'}, {value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'} ]}/> @@ -114,6 +114,6 @@
SKILL CHIPABILITYQUALITY2-PIECE BONUS3-PIECE TALENTROLL POOLSSTATE
{#each data.sets as item} -
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}
{ability(String(item.specialization_ability_id ?? ''))?.name ?? 'UNASSIGNED'}{ability(String(item.specialization_ability_id ?? '')) ? 'EXCLUSIVE ABILITY' : 'LEGACY RECORD'}
SUPHE{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}
{item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.effects.length ?? 0} SCALED EFFECTS
{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
+
{item.name}{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}
{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}EXCLUSIVE ABILITY
SUPHE{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}
{item.talent?.name ?? 'NOT CONFIGURED'}{item.talent?.effects.length ?? 0} SCALED EFFECTS
{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}{#if item.pending}{:else}{/if}
{:else}
NO SKILL CHIP SETS REGISTEREDAdd the first Superior and High-end Skill Chip definition.
{/each}
diff --git a/src/routes/isac/weaponmods/+page.server.ts b/src/routes/isac/weaponmods/+page.server.ts index 14fb47d..67d4854 100644 --- a/src/routes/isac/weaponmods/+page.server.ts +++ b/src/routes/isac/weaponmods/+page.server.ts @@ -70,7 +70,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => { 'slotType', 'isExotic', 'exoticWeaponId', 'compatibleCategories', 'qualityValues', 'secondaryRanges', 'effects', 'exoticAttributes' ] }]) ]); - const pendingRows = filter === 'archived' ? [] : pendingMods.filter((item) => + const currentPendingMods = pendingMods.filter((item) => Array.isArray(item.compatibleCategories)); + const pendingRows = filter === 'archived' ? [] : currentPendingMods.filter((item) => (!search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) && (filter !== 'regular' || !item.exoticWeaponId) && (filter !== 'exotic' || Boolean(item.exoticWeaponId)) ); @@ -95,7 +96,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { slot_type: item.slotType, exotic_weapon_id: item.exoticWeaponId, exotic_weapon_name: availableWeapons.find((weapon) => weapon.id === item.exoticWeaponId)?.name ?? '', - compatibleCategories: Array.isArray(item.compatibleCategories) ? item.compatibleCategories : [item.compatibleCategories].filter(Boolean), + compatibleCategories: item.compatibleCategories, qualityValues: parseJson(String(item.qualityValues), []), secondaryRanges: parseJson(String(item.secondaryRanges), []), exoticAttributes: parseJson(String(item.exoticAttributes), []), @@ -128,7 +129,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { slots: slots.map((name) => ({ id: name, name })), qualities, weaponCategories: weaponCategories.map((name) => ({ id: name, name: name.toUpperCase() })), - summary: { ...summary, total: Number(summary?.total ?? 0) + pendingMods.length, pending: pendingMods.length }, + summary: { ...summary, total: Number(summary?.total ?? 0) + currentPendingMods.length, pending: currentPendingMods.length }, filter, pagination, search }; };