refactor: remove retired data format support
This commit is contained in:
+2
-2
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.' });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user