refactor: remove retired data format support

This commit is contained in:
JB
2026-07-26 21:01:36 -04:00
parent 6ea45c05fa
commit 8b80e55e6c
17 changed files with 385 additions and 158 deletions
+2 -2
View File
@@ -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 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_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 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 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 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)); 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 ( CREATE TABLE IF NOT EXISTS skill_chip_sets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, 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)), active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
+7 -2
View File
@@ -111,7 +111,10 @@ The workflow:
7. waits for `/api/health/database` to report healthy; 7. waits for `/api/health/database` to report healthy;
8. retains the logs and exits unsuccessfully if health verification fails. 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 The scheduler calls a bearer-authenticated endpoint on the application service
once per minute. Scheduled message delivery therefore uses the web process's once per minute. Scheduled message delivery therefore uses the web process's
existing embedded-database connection instead of opening the locked database 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 Initialize the first staging release from the development database, including
users, roles, builds, guides, proposals, audit history, avatars, and uploaded users, roles, builds, guides, proposals, audit history, avatars, and uploaded
game-data icons. The staging importer refuses a target database that contains 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 First stop this project's development server. The embedded database must not be
open while its complete transfer bundle is created: open while its complete transfer bundle is created:
+6 -6
View File
@@ -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 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`. 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 The registry repeats the assigned parent beside each child so exclusivity can be
audited without opening the editor. Legacy records may remain visibly audited without opening the editor. Records that require an exclusive parent
`UNASSIGNED`, but creation, restoration, and successful edits require a valid must always have a valid active assignment. Capacity rules are enforced on the
active parent. Capacity rules are enforced on the server and never communicated server and never communicated by color alone.
by color alone.
Rank cards present the complete rank description, structured effects, point Rank cards present the complete rank description, structured effects, point
cost, Credits, and material cost together. Specialized Alpha, Beta, and Gamma 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. 49-point limit. Cost telemetry lists Credits and components beside each rank.
Saved loadouts store focus, node ranks, nine Skill Chips, effect modes, and Saved loadouts store focus, node ranks, nine Skill Chips, effect modes, and
combat assumptions. Version 1 loadouts keep their three legacy chips in an combat assumptions. The builder accepts only the current complete structure.
unassigned recovery list until the user places them under compatible abilities. Invalid local drafts are discarded; invalid server records are unavailable and
removed through a backed-up database migration.
### Calculation revision controls ### Calculation revision controls
+10 -9
View File
@@ -275,11 +275,11 @@ distinct secondary stats from those separate pools.
Every active Skill Chip definition belongs exclusively to one active Every active Skill Chip definition belongs exclusively to one active
specialization ability through `skill_chip_sets.specialization_ability_id`. specialization ability through `skill_chip_sets.specialization_ability_id`.
An ability has capacity for exactly three active definitions. The save and An ability has capacity for exactly three active definitions. The save and
restore actions enforce that capacity, while legacy rows may remain nullable restore actions enforce that capacity, and the foreign key is required for
until they are edited. A specialization focus is builder-ready only when each every row. A specialization focus is builder-ready only when each of its three
of its three assigned abilities has all three active Skill Chip definitions. assigned abilities has all three active Skill Chip definitions. Skill Chip
Skill Chip mutations refresh readiness for focuses that reference both the mutations refresh readiness for focuses that reference both the previous and
previous and resulting ability. resulting ability.
Both set-bonus progression systems use the number of equipped High-end chips as 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 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, 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 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 focus abilities owns three chip positions. Runtime readers accept
the three old chips in `specialization.unassignedChips`; the builder requires only the exact version 2 structure. Local drafts, saved builds, shared builds,
the user to place them under compatible abilities before it reports a complete and calibration snapshots reject older or partial structures instead of
calculation. repairing them. Database migration removes obsolete snapshots after the
required environment backup.
# Versioned combat calculations # Versioned combat calculations
+184 -9
View File
@@ -4,6 +4,80 @@ import { dirname, resolve } from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { initializeDefaultUsers } from './default-users.mjs'; 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 databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
const schemaPath = resolve('./database/schema.sql'); const schemaPath = resolve('./database/schema.sql');
let databaseAlreadyExisted = true; 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'" "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='os_protocol_attribute_effects'"
)).get(); )).get();
if (legacyProtocolEffects) { if (legacyProtocolEffects) {
const legacyProtocolEffectCount = await (await database.prepare( console.log('Removing the retired OS Protocol attribute-effects table.');
'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.');
await database.exec('DROP TABLE os_protocol_attribute_effects'); 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) { if (buildColumns.length) {
await database.exec("UPDATE builds SET visibility='private' WHERE visibility='draft'"); 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)'); 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.'); 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)'); 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)'); 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()}' await database.exec(`UPDATE specialization_focuses SET builder_ready=0,updated_at='${new Date().toISOString()}'
WHERE builder_ready=1 AND ( WHERE builder_ready=1 AND (
SELECT COUNT(*) FROM specialization_focus_abilities link SELECT COUNT(*) FROM specialization_focus_abilities link
+2 -2
View File
@@ -70,7 +70,7 @@ const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name)) .map((table) => String(table.name))
.filter((name) => !targetNames.has(name)); .filter((name) => !targetNames.has(name));
if (sourceOnlyTables.length) { 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('"', '""')}"`; const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
let restoredRows = 0; let restoredRows = 0;
@@ -80,7 +80,7 @@ try {
for (const row of [...targetTables].reverse()) { for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); 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) { for (const row of table.rows) {
const columns = Object.keys(row); const columns = Object.keys(row);
if (!columns.length) continue; if (!columns.length) continue;
+2 -2
View File
@@ -64,7 +64,7 @@ const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name)) .map((table) => String(table.name))
.filter((name) => !targetNames.has(name)); .filter((name) => !targetNames.has(name));
if (sourceOnlyTables.length) { 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('"', '""')}"`; const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
let restoredRows = 0; let restoredRows = 0;
@@ -74,7 +74,7 @@ try {
for (const row of [...targetTables].reverse()) { for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); 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) { for (const row of table.rows) {
const columns = Object.keys(row); const columns = Object.keys(row);
if (!columns.length) continue; if (!columns.length) continue;
-5
View File
@@ -318,11 +318,6 @@ export function evaluateLoadoutCombat(
else if ((focus?.abilities.length ?? 0) !== 3) missing.push('Three configured focus abilities'); else if ((focus?.abilities.length ?? 0) !== 3) missing.push('Three configured focus abilities');
const missingChips = draft.chips.filter((chip) => !chip.setId).length; const missingChips = draft.chips.filter((chip) => !chip.setId).length;
if (missingChips) missing.push(`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`); 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; return missing;
}; };
+52
View File
@@ -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<string, unknown>;
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<string, unknown> };
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<string, unknown> };
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();
});
});
+78 -63
View File
@@ -11,7 +11,6 @@ export type SpecializationSelection = {
nodeRanks: Record<string, number>; nodeRanks: Record<string, number>;
effectStates: Record<string, ConditionalEffectSelection>; effectStates: Record<string, ConditionalEffectSelection>;
combatProfile: CombatProfile; combatProfile: CombatProfile;
unassignedChips: ChipSelection[];
}; };
export type LoadoutDraft = { export type LoadoutDraft = {
version: 2; version: 2;
@@ -24,11 +23,6 @@ export type LoadoutDraft = {
specialization: SpecializationSelection; specialization: SpecializationSelection;
}; };
type LegacyLoadoutDraft = Omit<LoadoutDraft, 'version' | 'chips' | 'specialization'> & {
version: 1;
chips: [ChipSelection, ChipSelection, ChipSelection];
};
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const; export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
export const blankRoll = (): Roll => ({ statId: '', value: 0 }); 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: [] }); 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: '', focusId: '',
nodeRanks: {}, nodeRanks: {},
effectStates: {}, effectStates: {},
combatProfile: defaultCombatProfile(), combatProfile: defaultCombatProfile()
unassignedChips: []
} }
}); });
export function migrateLoadoutDraft(value: unknown): LoadoutDraft | null { const isRecord = (value: unknown): value is Record<string, unknown> =>
if (!value || typeof value !== 'object') return null; Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const draft = value as { const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
version?: number; Object.keys(value).length === keys.length && keys.every((key) => key in value);
buildName?: string; const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
gear?: Record<string, GearSelection>; const isNumberRecord = (value: unknown) =>
weapons?: WeaponSelection[]; isRecord(value) && Object.values(value).every((entry) => typeof entry === 'number' && Number.isFinite(entry));
chips?: ChipSelection[]; const isRoll = (value: unknown): value is Roll =>
protocol?: ProtocolSelection; isRecord(value)
activeTalents?: string[]; && hasExactKeys(value, ['statId', 'value'])
specialization?: SpecializationSelection; && typeof value.statId === 'string'
}; && isFiniteNumber(value.value);
if (typeof draft.buildName !== 'string' || !draft.gear || !Array.isArray(draft.weapons) const isRollPair = (value: unknown): value is [Roll, Roll] =>
|| draft.weapons.length !== 2 || !draft.protocol || !Array.isArray(draft.chips)) return null; Array.isArray(value) && value.length === 2 && value.every(isRoll);
if (draft.version === 1 && draft.chips.length === 3) { const isGearSelection = (value: unknown): value is GearSelection =>
const migrated = createDraft(); isRecord(value)
return { && hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots'])
...migrated, && ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string')
...draft, && ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
version: 2, && isRollPair(value.rolls)
gear: draft.gear, && Array.isArray(value.augmentSlots)
weapons: draft.weapons as [WeaponSelection, WeaponSelection], && value.augmentSlots.every(isFiniteNumber);
chips: migrated.chips, const isWeaponSelection = (value: unknown): value is WeaponSelection =>
protocol: draft.protocol, isRecord(value)
activeTalents: draft.activeTalents ?? [], && hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls'])
specialization: { && ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string')
...migrated.specialization, && ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
unassignedChips: draft.chips.map((chip) => structuredClone(chip)) && isRollPair(value.rolls);
} const isChipSelection = (value: unknown): value is ChipSelection =>
}; isRecord(value)
} && hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary'])
if (draft.version !== 2) return null; && typeof value.setId === 'string'
const fresh = createDraft(); && (value.quality === 'Superior' || value.quality === 'High-end')
const chips = [...draft.chips.slice(0, 9)]; && isRoll(value.primary)
while (chips.length < 9) chips.push(blankChip()); && isRollPair(value.secondary);
return { const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
...fresh, isRecord(value)
...draft, && hasExactKeys(value, ['itemId', 'level', 'talentUpgrades'])
version: 2, && typeof value.itemId === 'string'
gear: draft.gear, && isFiniteNumber(value.level)
weapons: draft.weapons as [WeaponSelection, WeaponSelection], && isFiniteNumber(value.talentUpgrades);
chips: chips as LoadoutDraft['chips'], const isCombatProfile = (value: unknown): value is CombatProfile =>
protocol: draft.protocol, isRecord(value)
activeTalents: draft.activeTalents ?? [], && hasExactKeys(value, ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'])
specialization: { && ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].every((key) => isFiniteNumber(value[key]));
...fresh.specialization, const isEffectStates = (value: unknown): value is Record<string, ConditionalEffectSelection> =>
...(draft.specialization ?? {}), isRecord(value) && Object.values(value).every((entry) => {
nodeRanks: { ...(draft.specialization?.nodeRanks ?? {}) }, if (!isRecord(entry)
effectStates: { ...(draft.specialization?.effectStates ?? {}) }, || !hasExactKeys(entry, entry.overrides === undefined ? ['mode'] : ['mode', 'overrides'])
combatProfile: { ...fresh.specialization.combatProfile, ...(draft.specialization?.combatProfile ?? {}) }, || !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
unassignedChips: Array.isArray(draft.specialization?.unassignedChips) return entry.overrides === undefined || (
? draft.specialization.unassignedChips 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) => { export const clamp = (value: unknown, min: unknown, max: unknown) => {
+2 -2
View File
@@ -3,7 +3,7 @@ import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { getSessionUser } from '$lib/server/auth/session'; import { getSessionUser } from '$lib/server/auth/session';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { migrateLoadoutDraft } from '$lib/builder/loadout'; import { parseLoadoutDraft } from '$lib/builder/loadout';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
const validVisibility = new Set(['private', 'unlisted', 'public']); const validVisibility = new Set(['private', 'unlisted', 'public']);
@@ -13,7 +13,7 @@ const slugify = (value: string) => value.toLowerCase().normalize('NFKD')
function parseLoadout(raw: FormDataEntryValue | null) { function parseLoadout(raw: FormDataEntryValue | null) {
if (typeof raw !== 'string' || raw.length > 250_000) return null; if (typeof raw !== 'string' || raw.length > 250_000) return null;
try { try {
return migrateLoadoutDraft(JSON.parse(raw)); return parseLoadoutDraft(JSON.parse(raw));
} catch { } catch {
return null; return null;
} }
+9 -27
View File
@@ -14,12 +14,12 @@
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat'; import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { toast } from '$lib/toast'; import { toast } from '$lib/toast';
import { 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 type LoadoutDraft, type Roll
} from '$lib/builder/loadout'; } from '$lib/builder/loadout';
let { data, form } = $props(); let { data, form } = $props();
let draft = $state<LoadoutDraft>(untrack(() => migrateLoadoutDraft(data.initialBuild?.loadout) ?? createDraft())); let draft = $state<LoadoutDraft>(untrack(() => parseLoadoutDraft(data.initialBuild?.loadout) ?? createDraft()));
let active = $state('gear:Holster'); let active = $state('gear:Holster');
let saved = $state(false); let saved = $state(false);
let message = $state(''); let message = $state('');
@@ -138,32 +138,29 @@
const selectFocus = (focusId: string) => { const selectFocus = (focusId: string) => {
const next = allFocuses.find((focus: any) => focus.id === focusId); const next = allFocuses.find((focus: any) => focus.id === focusId);
if (!next) return; if (!next) return;
const unassigned = [...draft.specialization.unassignedChips]; const displaced: LoadoutDraft['chips'][number][] = [];
for (let index = 0; index < draft.chips.length; index += 1) { for (let index = 0; index < draft.chips.length; index += 1) {
const chip = draft.chips[index]; const chip = draft.chips[index];
const ability = next.abilities[Math.floor(index / 3)]; const ability = next.abilities[Math.floor(index / 3)];
const definition = data.chipSets.find((item: any) => item.id === chip.setId); const definition = data.chipSets.find((item: any) => item.id === chip.setId);
if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) { if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) {
unassigned.push(structuredClone(chip)); displaced.push(structuredClone(chip));
draft.chips[index] = createDraft().chips[index]; draft.chips[index] = createDraft().chips[index];
} }
} }
for (let index = unassigned.length - 1; index >= 0; index -= 1) { for (const chip of displaced) {
const chip = unassigned[index];
const definition = data.chipSets.find((item: any) => item.id === chip.setId); const definition = data.chipSets.find((item: any) => item.id === chip.setId);
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id); const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id);
if (abilityIndex < 0) continue; if (abilityIndex < 0) continue;
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId); const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
if (targetIndex === undefined) continue; if (targetIndex === undefined) continue;
draft.chips[targetIndex] = structuredClone(chip); draft.chips[targetIndex] = structuredClone(chip);
unassigned.splice(index, 1);
} }
draft.specialization = { draft.specialization = {
...draft.specialization, ...draft.specialization,
focusId, focusId,
nodeRanks: {}, nodeRanks: {},
effectStates: {}, effectStates: {}
unassignedChips: unassigned
}; };
active = 'specialization:focus'; active = 'specialization:focus';
}; };
@@ -304,28 +301,17 @@
}); });
onMount(() => { onMount(() => {
if (data.initialBuild) return; 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; if (!stored) return;
try { try {
const parsed = migrateLoadoutDraft(JSON.parse(stored)); const parsed = parseLoadoutDraft(JSON.parse(stored));
if (parsed) { if (parsed) {
draft = 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 = ''; if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true; saved = true;
} } else localStorage.removeItem('shd-build-loadout-draft-v2');
} catch { } catch {
localStorage.removeItem('shd-build-loadout-draft-v2'); localStorage.removeItem('shd-build-loadout-draft-v2');
localStorage.removeItem('shd-build-loadout-draft-v1');
} }
}); });
$effect(() => { $effect(() => {
@@ -342,7 +328,6 @@
}); });
function saveDraft() { function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v2', JSON.stringify(draft)); localStorage.setItem('shd-build-loadout-draft-v2', JSON.stringify(draft));
localStorage.removeItem('shd-build-loadout-draft-v1');
saved = true; saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.'); toast.success('Draft saved locally', 'This loadout is available on this device.');
} }
@@ -397,9 +382,6 @@
{#if activeParts[0] === 'specialization'} {#if activeParts[0] === 'specialization'}
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div> <div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/> <BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
{#if draft.specialization.unassignedChips.length}
<div class="builder-remediation" role="status"><strong>UNRESOLVED LEGACY SKILL CHIPS</strong><span>{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.</span></div>
{/if}
{#if selectedFocus} {#if selectedFocus}
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div> <div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
<div class="builder-ability-chip-panels"> <div class="builder-ability-chip-panels">
+4 -4
View File
@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import { getSessionUser } from '$lib/server/auth/session'; 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 { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat'; import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
import type { PageServerLoad } from './$types'; 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'); if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found');
let loadout: LoadoutDraft; let loadout: LoadoutDraft;
try { try {
const migrated = migrateLoadoutDraft(JSON.parse(String(build.loadout_json))); const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
if (!migrated) error(404, 'Build data is unavailable'); if (!parsed) error(404, 'Build data is unavailable');
loadout = migrated; loadout = parsed;
} catch { } catch {
error(404, 'Build data is unavailable'); error(404, 'Build data is unavailable');
} }
+4 -4
View File
@@ -6,7 +6,7 @@ import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit'; import { recordIsacAudit } from '$lib/server/audit';
import { getPagination, getRegistrySearch } from '$lib/server/pagination'; import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { migrateLoadoutDraft } from '$lib/builder/loadout'; import { parseLoadoutDraft } from '$lib/builder/loadout';
const operators = new Set([ const operators = new Set([
'base_damage', 'flat_add', 'additive_percent', 'multiplicative_percent', 'base_damage', 'flat_add', 'additive_percent', 'multiplicative_percent',
@@ -250,8 +250,8 @@ export const actions: Actions = {
const snapshotText = String(form.get('loadoutSnapshot') ?? '').trim(); const snapshotText = String(form.get('loadoutSnapshot') ?? '').trim();
let snapshot: unknown; let snapshot: unknown;
try { snapshot = JSON.parse(snapshotText); } catch { return fail(400, { calibrationError: 'The loadout snapshot is not valid JSON.' }); } try { snapshot = JSON.parse(snapshotText); } catch { return fail(400, { calibrationError: 'The loadout snapshot is not valid JSON.' }); }
const migrated = migrateLoadoutDraft(snapshot); const parsedLoadout = parseLoadoutDraft(snapshot);
if (!migrated) return fail(400, { calibrationError: 'The loadout snapshot is not a recognized SHD.build loadout.' }); if (!parsedLoadout) return fail(400, { calibrationError: 'The loadout snapshot does not match the current SHD.build loadout format.' });
const observed = { const observed = {
bodyHit: optionalNumber(form.get('bodyHit')), bodyHit: optionalNumber(form.get('bodyHit')),
criticalBodyHit: optionalNumber(form.get('criticalBodyHit')), 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.' }); if (id && !before) return fail(404, { calibrationError: 'That calibration case no longer exists.' });
const now = new Date().toISOString(); const now = new Date().toISOString();
const values = [ 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('gameVersion')) || null, clean(form.get('observedAt')) || null,
clean(form.get('notes')).slice(0, 4000) || null, clean(form.get('notes')).slice(0, 4000) || null,
observed.bodyHit, observed.criticalBodyHit, observed.headshot, observed.criticalHeadshot, observed.bodyHit, observed.criticalBodyHit, observed.headshot, observed.criticalHeadshot,
+16 -15
View File
@@ -46,12 +46,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies)); const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase(); const database = await getDatabase();
const requestedFilter = url.searchParams.get('filter') ?? ''; 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 { search, pattern } = getRegistrySearch(url);
const conditions = [ const conditions = [
...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0'] ...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0']
: filter === 'assigned' ? ['sets.specialization_ability_id IS NOT NULL'] : 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)'] : []), : 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"] : []) ...(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=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived, 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 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 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(), FROM skill_chip_sets`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(...args), (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) 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 !== 'assigned' || Boolean(set.abilityId))
.filter((set) => filter !== 'unassigned' || !set.abilityId)
.filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())) .filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
.map((set) => ({ .map((set) => ({
...set, ...set,
@@ -124,13 +127,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
min_value: Number(set.twoPieceMin), min_value: Number(set.twoPieceMin),
max_value: Number(set.twoPieceMax) max_value: Number(set.twoPieceMax)
}, },
primaryStatIds: Array.isArray(set.primaryStatIds) ? set.primaryStatIds : [set.primaryStatIds].filter(Boolean), primaryStatIds: set.primaryStatIds,
secondaryStatIds: Array.isArray(set.secondaryStatIds) ? set.secondaryStatIds : [set.secondaryStatIds].filter(Boolean), secondaryStatIds: set.secondaryStatIds,
talent: { talent: {
name: set.talentName, name: set.talentName,
description_template: set.descriptionTemplate, description_template: set.descriptionTemplate,
effects: (() => { try { return JSON.parse(String(set.talentEffects ?? '[]')); } catch { return []; } })(), 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 { return {
@@ -158,10 +161,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
active: Number(summary?.active ?? 0), active: Number(summary?.active ?? 0),
archived: Number(summary?.archived ?? 0), archived: Number(summary?.archived ?? 0),
configured: Number(summary?.configured ?? 0), configured: Number(summary?.configured ?? 0),
total: Number(summary?.total ?? 0) + pendingSets.length, total: Number(summary?.total ?? 0) + currentPendingSets.length,
assigned: Number(summary?.assigned ?? 0) + pendingSets.filter((set) => Boolean(set.abilityId)).length, assigned: Number(summary?.assigned ?? 0) + currentPendingSets.length,
unassigned: Number(summary?.unassigned ?? 0) + pendingSets.filter((set) => !set.abilityId).length, pending: currentPendingSets.length
pending: pendingSets.length
}, },
filter, pagination, search filter, pagination, search
}; };
@@ -251,7 +253,7 @@ export const actions: Actions = {
if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' }); if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' });
const database = await getDatabase(); 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 (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) }); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.archived', entityId: id, before, after: await snapshot(id) });
return { skillChipArchived: true }; return { skillChipArchived: true };
}, },
@@ -260,8 +262,7 @@ export const actions: Actions = {
const id = String((await request.formData()).get('id') ?? ''); const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id); const before = await snapshot(id);
if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' }); if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' });
const abilityId = String(before.record.specialization_ability_id ?? ''); 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 database = await getDatabase(); const database = await getDatabase();
if (!await (await database.prepare('SELECT id FROM specialization_abilities WHERE id=? AND active=1')).get(abilityId)) { 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.' }); return fail(400, { skillChipError: 'The assigned specialization ability must be active before restoring this Skill Chip.' });
+3 -3
View File
@@ -38,7 +38,7 @@
const edit = (item: any) => { const edit = (item: any) => {
editing = { editing = {
...item, ...item,
abilityId: item.specialization_ability_id ?? '', abilityId: String(item.specialization_ability_id),
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' }, twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
primaryStatIds: [...item.primaryStatIds], primaryStatIds: [...item.primaryStatIds],
secondaryStatIds: [...item.secondaryStatIds], secondaryStatIds: [...item.secondaryStatIds],
@@ -69,7 +69,7 @@
{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'}, {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:'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:'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'} {value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
]}/> ]}/>
@@ -114,6 +114,6 @@
<div class="dashboard-table"> <div class="dashboard-table">
<div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>ABILITY</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div> <div class="dashboard-row skill-chip-row table-heading"><span>SKILL CHIP</span><span>ABILITY</span><span>QUALITY</span><span>2-PIECE BONUS</span><span>3-PIECE TALENT</span><span>ROLL POOLS</span><span>STATE</span><span></span></div>
{#each data.sets as item} {#each data.sets as item}
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id ?? ''))?.name ?? 'UNASSIGNED'}</strong><small>{ability(String(item.specialization_ability_id ?? '')) ? 'EXCLUSIVE ABILITY' : 'LEGACY RECORD'}</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{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'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div> <div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{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'}</span><div><strong>{item.talent?.name ?? 'NOT CONFIGURED'}</strong><small>{item.talent?.effects.length ?? 0} SCALED EFFECTS</small></div><span>{item.primaryStatIds.length} PRIMARY · {item.secondaryStatIds.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each} {:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first Superior and High-end Skill Chip definition.</span></div>{/each}
</div> </div>
+4 -3
View File
@@ -70,7 +70,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'slotType', 'isExotic', 'exoticWeaponId', 'compatibleCategories', 'qualityValues', 'secondaryRanges', 'effects', 'exoticAttributes' '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())) && (!search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) &&
(filter !== 'regular' || !item.exoticWeaponId) && (filter !== 'exotic' || Boolean(item.exoticWeaponId)) (filter !== 'regular' || !item.exoticWeaponId) && (filter !== 'exotic' || Boolean(item.exoticWeaponId))
); );
@@ -95,7 +96,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
slot_type: item.slotType, slot_type: item.slotType,
exotic_weapon_id: item.exoticWeaponId, exotic_weapon_id: item.exoticWeaponId,
exotic_weapon_name: availableWeapons.find((weapon) => weapon.id === item.exoticWeaponId)?.name ?? '', 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), []), qualityValues: parseJson(String(item.qualityValues), []),
secondaryRanges: parseJson(String(item.secondaryRanges), []), secondaryRanges: parseJson(String(item.secondaryRanges), []),
exoticAttributes: parseJson(String(item.exoticAttributes), []), exoticAttributes: parseJson(String(item.exoticAttributes), []),
@@ -128,7 +129,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
slots: slots.map((name) => ({ id: name, name })), slots: slots.map((name) => ({ id: name, name })),
qualities, qualities,
weaponCategories: weaponCategories.map((name) => ({ id: name, name: name.toUpperCase() })), 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 filter, pagination, search
}; };
}; };