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 avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC);
CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC);
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), loadout_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), loadout_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility, updated_at DESC);
CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id));
@@ -621,7 +621,7 @@ CREATE TABLE IF NOT EXISTS os_protocol_talent_effects (
CREATE TABLE IF NOT EXISTS skill_chip_sets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
specialization_ability_id TEXT REFERENCES specialization_abilities(id),
specialization_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
+7 -2
View File
@@ -111,7 +111,10 @@ The workflow:
7. waits for `/api/health/database` to report healthy;
8. retains the logs and exits unsuccessfully if health verification fails.
The outage covers backup, migration, and container startup.
The outage covers backup, migration, and container startup. Migrations may also
remove records that use retired data formats when a release explicitly ends
compatibility. The deployment backup remains the recovery source for those
records; the application does not repair or reinterpret them at runtime.
The scheduler calls a bearer-authenticated endpoint on the application service
once per minute. Scheduled message delivery therefore uses the web process's
existing embedded-database connection instead of opening the locked database
@@ -123,7 +126,9 @@ consolidation, and email digest checks and is not published through a host port.
Initialize the first staging release from the development database, including
users, roles, builds, guides, proposals, audit history, avatars, and uploaded
game-data icons. The staging importer refuses a target database that contains
users, builds, audits, or proposals.
users, builds, audits, or proposals. It also requires the source and target
table sets to match exactly; snapshots with retired or unknown tables are
rejected.
First stop this project's development server. The embedded database must not be
open while its complete transfer bundle is created:
+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
relationship and capacity in plain language, and shows current usage as `N / N`.
The registry repeats the assigned parent beside each child so exclusivity can be
audited without opening the editor. Legacy records may remain visibly
`UNASSIGNED`, but creation, restoration, and successful edits require a valid
active parent. Capacity rules are enforced on the server and never communicated
by color alone.
audited without opening the editor. Records that require an exclusive parent
must always have a valid active assignment. Capacity rules are enforced on the
server and never communicated by color alone.
Rank cards present the complete rank description, structured effects, point
cost, Credits, and material cost together. Specialized Alpha, Beta, and Gamma
@@ -1762,8 +1761,9 @@ user buys every available Rank 1 and Rank 2 in preceding rows and enforces the
49-point limit. Cost telemetry lists Credits and components beside each rank.
Saved loadouts store focus, node ranks, nine Skill Chips, effect modes, and
combat assumptions. Version 1 loadouts keep their three legacy chips in an
unassigned recovery list until the user places them under compatible abilities.
combat assumptions. The builder accepts only the current complete structure.
Invalid local drafts are discarded; invalid server records are unavailable and
removed through a backed-up database migration.
### Calculation revision controls
+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
specialization ability through `skill_chip_sets.specialization_ability_id`.
An ability has capacity for exactly three active definitions. The save and
restore actions enforce that capacity, while legacy rows may remain nullable
until they are edited. A specialization focus is builder-ready only when each
of its three assigned abilities has all three active Skill Chip definitions.
Skill Chip mutations refresh readiness for focuses that reference both the
previous and resulting ability.
restore actions enforce that capacity, and the foreign key is required for
every row. A specialization focus is builder-ready only when each of its three
assigned abilities has all three active Skill Chip definitions. Skill Chip
mutations refresh readiness for focuses that reference both the previous and
resulting ability.
Both set-bonus progression systems use the number of equipped High-end chips as
the interpolation step. For `h` High-end chips, where `h` is zero through
@@ -308,10 +308,11 @@ Specialization proposal workflow.
Loadout schema version 2 stores one focus, the selected rank for each tree node,
nine Skill Chips, conditional-effect modes, and encounter assumptions. Each of
the three focus abilities owns three chip positions. Version 1 migration keeps
the three old chips in `specialization.unassignedChips`; the builder requires
the user to place them under compatible abilities before it reports a complete
calculation.
the three focus abilities owns three chip positions. Runtime readers accept
only the exact version 2 structure. Local drafts, saved builds, shared builds,
and calibration snapshots reject older or partial structures instead of
repairing them. Database migration removes obsolete snapshots after the
required environment backup.
# Versioned combat calculations
+184 -9
View File
@@ -4,6 +4,80 @@ import { dirname, resolve } from 'node:path';
import process from 'node:process';
import { initializeDefaultUsers } from './default-users.mjs';
const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value, keys) =>
isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => key in value);
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
const isRoll = (value) =>
hasExactKeys(value, ['statId', 'value']) && typeof value.statId === 'string' && isFiniteNumber(value.value);
const isRollPair = (value) => Array.isArray(value) && value.length === 2 && value.every(isRoll);
const isGearSelection = (value) =>
hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots'])
&& ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string')
&& ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
&& isRollPair(value.rolls)
&& Array.isArray(value.augmentSlots)
&& value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) =>
hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls'])
&& ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string')
&& ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
&& isRollPair(value.rolls);
const isChipSelection = (value) =>
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary'])
&& typeof value.setId === 'string'
&& (value.quality === 'Superior' || value.quality === 'High-end')
&& isRoll(value.primary)
&& isRollPair(value.secondary);
const isProtocolSelection = (value) =>
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades'])
&& typeof value.itemId === 'string'
&& isFiniteNumber(value.level)
&& isFiniteNumber(value.talentUpgrades);
const combatProfileKeys = ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'];
const isCombatProfile = (value) =>
hasExactKeys(value, combatProfileKeys) && combatProfileKeys.every((key) => isFiniteNumber(value[key]));
const isEffectStates = (value) =>
isRecord(value) && Object.values(value).every((entry) => {
if (!hasExactKeys(entry, entry?.overrides === undefined ? ['mode'] : ['mode', 'overrides'])
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
return entry.overrides === undefined || (
isRecord(entry.overrides)
&& Object.entries(entry.overrides).every(([key, override]) =>
combatProfileKeys.includes(key) && isFiniteNumber(override)
)
);
});
const isCurrentLoadoutSnapshot = (raw) => {
let value;
try {
value = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return false;
}
return hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization'])
&& value.version === 2
&& typeof value.buildName === 'string'
&& hasExactKeys(value.gear, gearSlots)
&& gearSlots.every((slot) => isGearSelection(value.gear[slot]))
&& Array.isArray(value.weapons)
&& value.weapons.length === 2
&& value.weapons.every(isWeaponSelection)
&& Array.isArray(value.chips)
&& value.chips.length === 9
&& value.chips.every(isChipSelection)
&& isProtocolSelection(value.protocol)
&& Array.isArray(value.activeTalents)
&& value.activeTalents.every((talent) => typeof talent === 'string')
&& hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile'])
&& typeof value.specialization.focusId === 'string'
&& isRecord(value.specialization.nodeRanks)
&& Object.values(value.specialization.nodeRanks).every(isFiniteNumber)
&& isEffectStates(value.specialization.effectStates)
&& isCombatProfile(value.specialization.combatProfile);
};
const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db');
const schemaPath = resolve('./database/schema.sql');
let databaseAlreadyExisted = true;
@@ -106,15 +180,7 @@ const legacyProtocolEffects = await (await database.prepare(
"SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='os_protocol_attribute_effects'"
)).get();
if (legacyProtocolEffects) {
const legacyProtocolEffectCount = await (await database.prepare(
'SELECT COUNT(*) AS count FROM os_protocol_attribute_effects'
)).get();
if (Number(legacyProtocolEffectCount?.count ?? 0) > 0) {
throw new Error(
'Legacy OS Protocol attribute effects still contain rows. Export and reconcile them before removing the retired table.'
);
}
console.log('Removing the empty legacy OS Protocol attribute-effects table.');
console.log('Removing the retired OS Protocol attribute-effects table.');
await database.exec('DROP TABLE os_protocol_attribute_effects');
}
@@ -621,6 +687,58 @@ if (buildColumns.length && !buildColumns.some((column) => column.name === 'loado
}
if (buildColumns.length) {
await database.exec("UPDATE builds SET visibility='private' WHERE visibility='draft'");
const builds = await (await database.prepare('SELECT id,loadout_json FROM builds')).all();
const obsoleteBuildIds = builds
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
.map((build) => String(build.id));
if (obsoleteBuildIds.length) {
console.log(`Removing ${obsoleteBuildIds.length} build record(s) that do not use the current loadout format.`);
await database.exec('BEGIN');
try {
for (const id of obsoleteBuildIds) {
await (await database.prepare('UPDATE builds SET source_build_id=NULL WHERE source_build_id=?')).run(id);
await (await database.prepare('DELETE FROM builds WHERE id=?')).run(id);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
}
const currentBuildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all();
const loadoutColumn = currentBuildColumns.find((column) => column.name === 'loadout_json');
if (loadoutColumn && Number(loadoutColumn.notnull) !== 1) {
console.log('Requiring every build record to contain a current loadout snapshot.');
await database.exec('DROP INDEX IF EXISTS builds_visibility_updated_idx');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec('DROP TABLE IF EXISTS builds_current');
await database.exec(`CREATE TABLE builds_current (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
visibility TEXT NOT NULL DEFAULT 'private',
summary TEXT,
guide_body TEXT,
source_build_id TEXT REFERENCES builds_current(id),
loadout_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO builds_current(id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at)
SELECT id,owner_id,title,slug,visibility,summary,guide_body,source_build_id,loadout_json,created_at,updated_at FROM builds`);
await database.exec('DROP TABLE builds');
await database.exec('ALTER TABLE builds_current RENAME TO builds');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
}
await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)');
@@ -629,7 +747,64 @@ if (skillChipColumns.length && !skillChipColumns.some((column) => column.name ==
console.log('Associating Skill Chip definitions with specialization abilities.');
await database.exec('ALTER TABLE skill_chip_sets ADD COLUMN specialization_ability_id TEXT REFERENCES specialization_abilities(id)');
}
const currentSkillChipColumns = await (await database.prepare("PRAGMA table_info('skill_chip_sets')")).all();
const skillChipAbilityColumn = currentSkillChipColumns.find((column) => column.name === 'specialization_ability_id');
if (skillChipAbilityColumn && Number(skillChipAbilityColumn.notnull) !== 1) {
const unassignedSkillChips = await (await database.prepare(
'SELECT id FROM skill_chip_sets WHERE specialization_ability_id IS NULL'
)).all();
if (unassignedSkillChips.length) {
console.log(`Removing ${unassignedSkillChips.length} Skill Chip record(s) without a specialization ability.`);
for (const row of unassignedSkillChips) {
await (await database.prepare('DELETE FROM skill_chip_sets WHERE id=?')).run(row.id);
}
}
console.log('Requiring every Skill Chip definition to reference a specialization ability.');
await database.exec('DROP INDEX IF EXISTS skill_chip_sets_ability_idx');
await database.exec('DROP INDEX IF EXISTS skill_chip_sets_name_nocase_idx');
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec('DROP TABLE IF EXISTS skill_chip_sets_current');
await database.exec(`CREATE TABLE skill_chip_sets_current (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
specialization_ability_id TEXT NOT NULL REFERENCES specialization_abilities(id),
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO skill_chip_sets_current(id,name,specialization_ability_id,active,created_at,updated_at)
SELECT id,name,specialization_ability_id,active,created_at,updated_at FROM skill_chip_sets`);
await database.exec('DROP TABLE skill_chip_sets');
await database.exec('ALTER TABLE skill_chip_sets_current RENAME TO skill_chip_sets');
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE)');
await database.exec('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active)');
const calibrationCases = await (await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases'
)).all();
const obsoleteCalibrationIds = calibrationCases
.filter((calibrationCase) => !isCurrentLoadoutSnapshot(calibrationCase.loadout_snapshot_json))
.map((calibrationCase) => String(calibrationCase.id));
if (obsoleteCalibrationIds.length) {
console.log(`Removing ${obsoleteCalibrationIds.length} calculation calibration case(s) with obsolete loadout snapshots.`);
for (const id of obsoleteCalibrationIds) {
await (await database.prepare('DELETE FROM calculation_calibration_cases WHERE id=?')).run(id);
}
}
const foreignKeyFailures = await (await database.prepare('PRAGMA foreign_key_check')).all();
if (foreignKeyFailures.length) {
throw new Error(`Schema migration left ${foreignKeyFailures.length} foreign-key violation(s).`);
}
await database.exec(`UPDATE specialization_focuses SET builder_ready=0,updated_at='${new Date().toISOString()}'
WHERE builder_ready=1 AND (
SELECT COUNT(*) FROM specialization_focus_abilities link
+2 -2
View File
@@ -70,7 +70,7 @@ const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
if (sourceOnlyTables.length) {
console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`);
throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`);
}
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
let restoredRows = 0;
@@ -80,7 +80,7 @@ try {
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) {
for (const table of bundle.database.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
+2 -2
View File
@@ -64,7 +64,7 @@ const sourceOnlyTables = bundle.database.tables
.map((table) => String(table.name))
.filter((name) => !targetNames.has(name));
if (sourceOnlyTables.length) {
console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`);
throw new Error(`Bootstrap snapshot contains tables outside the current target schema: ${sourceOnlyTables.join(', ')}`);
}
const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`;
let restoredRows = 0;
@@ -74,7 +74,7 @@ try {
for (const row of [...targetTables].reverse()) {
await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) {
for (const table of bundle.database.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
-5
View File
@@ -318,11 +318,6 @@ export function evaluateLoadoutCombat(
else if ((focus?.abilities.length ?? 0) !== 3) missing.push('Three configured focus abilities');
const missingChips = draft.chips.filter((chip) => !chip.setId).length;
if (missingChips) missing.push(`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`);
if (draft.specialization.unassignedChips.length) {
missing.push(
`${draft.specialization.unassignedChips.length} unresolved legacy Skill Chip${draft.specialization.unassignedChips.length === 1 ? '' : 's'}`
);
}
return missing;
};
+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>;
effectStates: Record<string, ConditionalEffectSelection>;
combatProfile: CombatProfile;
unassignedChips: ChipSelection[];
};
export type LoadoutDraft = {
version: 2;
@@ -24,11 +23,6 @@ export type LoadoutDraft = {
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 blankRoll = (): Roll => ({ statId: '', value: 0 });
export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, firepower: 0, toughness: 0, engineering: 0, rolls: [blankRoll(), blankRoll()], augmentSlots: [] });
@@ -47,66 +41,87 @@ export const createDraft = (): LoadoutDraft => ({
focusId: '',
nodeRanks: {},
effectStates: {},
combatProfile: defaultCombatProfile(),
unassignedChips: []
combatProfile: defaultCombatProfile()
}
});
export function migrateLoadoutDraft(value: unknown): LoadoutDraft | null {
if (!value || typeof value !== 'object') return null;
const draft = value as {
version?: number;
buildName?: string;
gear?: Record<string, GearSelection>;
weapons?: WeaponSelection[];
chips?: ChipSelection[];
protocol?: ProtocolSelection;
activeTalents?: string[];
specialization?: SpecializationSelection;
};
if (typeof draft.buildName !== 'string' || !draft.gear || !Array.isArray(draft.weapons)
|| draft.weapons.length !== 2 || !draft.protocol || !Array.isArray(draft.chips)) return null;
if (draft.version === 1 && draft.chips.length === 3) {
const migrated = createDraft();
return {
...migrated,
...draft,
version: 2,
gear: draft.gear,
weapons: draft.weapons as [WeaponSelection, WeaponSelection],
chips: migrated.chips,
protocol: draft.protocol,
activeTalents: draft.activeTalents ?? [],
specialization: {
...migrated.specialization,
unassignedChips: draft.chips.map((chip) => structuredClone(chip))
}
};
}
if (draft.version !== 2) return null;
const fresh = createDraft();
const chips = [...draft.chips.slice(0, 9)];
while (chips.length < 9) chips.push(blankChip());
return {
...fresh,
...draft,
version: 2,
gear: draft.gear,
weapons: draft.weapons as [WeaponSelection, WeaponSelection],
chips: chips as LoadoutDraft['chips'],
protocol: draft.protocol,
activeTalents: draft.activeTalents ?? [],
specialization: {
...fresh.specialization,
...(draft.specialization ?? {}),
nodeRanks: { ...(draft.specialization?.nodeRanks ?? {}) },
effectStates: { ...(draft.specialization?.effectStates ?? {}) },
combatProfile: { ...fresh.specialization.combatProfile, ...(draft.specialization?.combatProfile ?? {}) },
unassignedChips: Array.isArray(draft.specialization?.unassignedChips)
? draft.specialization.unassignedChips
: []
}
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
Object.keys(value).length === keys.length && keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
const isNumberRecord = (value: unknown) =>
isRecord(value) && Object.values(value).every((entry) => typeof entry === 'number' && Number.isFinite(entry));
const isRoll = (value: unknown): value is Roll =>
isRecord(value)
&& hasExactKeys(value, ['statId', 'value'])
&& typeof value.statId === 'string'
&& isFiniteNumber(value.value);
const isRollPair = (value: unknown): value is [Roll, Roll] =>
Array.isArray(value) && value.length === 2 && value.every(isRoll);
const isGearSelection = (value: unknown): value is GearSelection =>
isRecord(value)
&& hasExactKeys(value, ['itemId', 'level', 'brandId', 'talentId', 'health', 'armor', 'firepower', 'toughness', 'engineering', 'rolls', 'augmentSlots'])
&& ['itemId', 'brandId', 'talentId'].every((key) => typeof value[key] === 'string')
&& ['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
&& isRollPair(value.rolls)
&& Array.isArray(value.augmentSlots)
&& value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value: unknown): value is WeaponSelection =>
isRecord(value)
&& hasExactKeys(value, ['itemId', 'level', 'tier', 'damage', 'firepower', 'toughness', 'engineering', 'talent1Id', 'talent2Id', 'rolls'])
&& ['itemId', 'talent1Id', 'talent2Id'].every((key) => typeof value[key] === 'string')
&& ['level', 'tier', 'damage', 'firepower', 'toughness', 'engineering'].every((key) => isFiniteNumber(value[key]))
&& isRollPair(value.rolls);
const isChipSelection = (value: unknown): value is ChipSelection =>
isRecord(value)
&& hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary'])
&& typeof value.setId === 'string'
&& (value.quality === 'Superior' || value.quality === 'High-end')
&& isRoll(value.primary)
&& isRollPair(value.secondary);
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
isRecord(value)
&& hasExactKeys(value, ['itemId', 'level', 'talentUpgrades'])
&& typeof value.itemId === 'string'
&& isFiniteNumber(value.level)
&& isFiniteNumber(value.talentUpgrades);
const isCombatProfile = (value: unknown): value is CombatProfile =>
isRecord(value)
&& hasExactKeys(value, ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'])
&& ['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].every((key) => isFiniteNumber(value[key]));
const isEffectStates = (value: unknown): value is Record<string, ConditionalEffectSelection> =>
isRecord(value) && Object.values(value).every((entry) => {
if (!isRecord(entry)
|| !hasExactKeys(entry, entry.overrides === undefined ? ['mode'] : ['mode', 'overrides'])
|| !['off', 'peak', 'encounter'].includes(String(entry.mode))) return false;
return entry.overrides === undefined || (
isRecord(entry.overrides)
&& Object.entries(entry.overrides).every(([key, override]) =>
['encounterSeconds', 'hitRate', 'headshotRate', 'firingActivity'].includes(key) && isFiniteNumber(override)
)
);
});
export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
if (!isRecord(value)
|| !hasExactKeys(value, ['version', 'buildName', 'gear', 'weapons', 'chips', 'protocol', 'activeTalents', 'specialization'])
|| value.version !== 2
|| typeof value.buildName !== 'string') return null;
const gear = value.gear;
if (!isRecord(gear) || Object.keys(gear).length !== gearSlots.length
|| !gearSlots.every((slot) => isGearSelection(gear[slot]))) return null;
if (!Array.isArray(value.weapons) || value.weapons.length !== 2 || !value.weapons.every(isWeaponSelection)) return null;
if (!Array.isArray(value.chips) || value.chips.length !== 9 || !value.chips.every(isChipSelection)) return null;
if (!isProtocolSelection(value.protocol) || !Array.isArray(value.activeTalents)
|| !value.activeTalents.every((talent) => typeof talent === 'string')) return null;
if (!isRecord(value.specialization)
|| !hasExactKeys(value.specialization, ['focusId', 'nodeRanks', 'effectStates', 'combatProfile'])
|| typeof value.specialization.focusId !== 'string'
|| !isNumberRecord(value.specialization.nodeRanks)
|| !isEffectStates(value.specialization.effectStates)
|| !isCombatProfile(value.specialization.combatProfile)) return null;
return structuredClone(value) as LoadoutDraft;
}
export const clamp = (value: unknown, min: unknown, max: unknown) => {
+2 -2
View File
@@ -3,7 +3,7 @@ import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { getSessionUser } from '$lib/server/auth/session';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { migrateLoadoutDraft } from '$lib/builder/loadout';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import type { Actions, PageServerLoad } from './$types';
const validVisibility = new Set(['private', 'unlisted', 'public']);
@@ -13,7 +13,7 @@ const slugify = (value: string) => value.toLowerCase().normalize('NFKD')
function parseLoadout(raw: FormDataEntryValue | null) {
if (typeof raw !== 'string' || raw.length > 250_000) return null;
try {
return migrateLoadoutDraft(JSON.parse(raw));
return parseLoadoutDraft(JSON.parse(raw));
} catch {
return null;
}
+9 -27
View File
@@ -14,12 +14,12 @@
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { toast } from '$lib/toast';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, migrateLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
} from '$lib/builder/loadout';
let { data, form } = $props();
let draft = $state<LoadoutDraft>(untrack(() => migrateLoadoutDraft(data.initialBuild?.loadout) ?? createDraft()));
let draft = $state<LoadoutDraft>(untrack(() => parseLoadoutDraft(data.initialBuild?.loadout) ?? createDraft()));
let active = $state('gear:Holster');
let saved = $state(false);
let message = $state('');
@@ -138,32 +138,29 @@
const selectFocus = (focusId: string) => {
const next = allFocuses.find((focus: any) => focus.id === focusId);
if (!next) return;
const unassigned = [...draft.specialization.unassignedChips];
const displaced: LoadoutDraft['chips'][number][] = [];
for (let index = 0; index < draft.chips.length; index += 1) {
const chip = draft.chips[index];
const ability = next.abilities[Math.floor(index / 3)];
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) {
unassigned.push(structuredClone(chip));
displaced.push(structuredClone(chip));
draft.chips[index] = createDraft().chips[index];
}
}
for (let index = unassigned.length - 1; index >= 0; index -= 1) {
const chip = unassigned[index];
for (const chip of displaced) {
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id);
if (abilityIndex < 0) continue;
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
if (targetIndex === undefined) continue;
draft.chips[targetIndex] = structuredClone(chip);
unassigned.splice(index, 1);
}
draft.specialization = {
...draft.specialization,
focusId,
nodeRanks: {},
effectStates: {},
unassignedChips: unassigned
effectStates: {}
};
active = 'specialization:focus';
};
@@ -304,28 +301,17 @@
});
onMount(() => {
if (data.initialBuild) return;
const stored = localStorage.getItem('shd-build-loadout-draft-v2') ?? localStorage.getItem('shd-build-loadout-draft-v1');
const stored = localStorage.getItem('shd-build-loadout-draft-v2');
if (!stored) return;
try {
const parsed = migrateLoadoutDraft(JSON.parse(stored));
const parsed = parseLoadoutDraft(JSON.parse(stored));
if (parsed) {
draft = parsed;
for (const slot of gearSlots) {
if (!Number.isInteger(draft.gear[slot].level)) draft.gear[slot].level = 50;
const item = gearItem(slot), core = coreAllocationRange(Number(item?.tier ?? 0), draft.gear[slot].level, 'gear');
for (const key of coreKeys) if (!Number.isFinite(draft.gear[slot][key])) draft.gear[slot][key] = core.min;
draft.gear[slot].rolls = [...(draft.gear[slot].rolls ?? []).slice(0, 2), blankRoll(), blankRoll()].slice(0, 2) as [Roll, Roll];
}
for (const weapon of draft.weapons) {
if (!Number.isInteger(weapon.level)) weapon.level = 50;
weapon.rolls = [...(weapon.rolls ?? []).slice(0, 2), blankRoll(), blankRoll()].slice(0, 2) as [Roll, Roll];
}
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
}
} else localStorage.removeItem('shd-build-loadout-draft-v2');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v2');
localStorage.removeItem('shd-build-loadout-draft-v1');
}
});
$effect(() => {
@@ -342,7 +328,6 @@
});
function saveDraft() {
localStorage.setItem('shd-build-loadout-draft-v2', JSON.stringify(draft));
localStorage.removeItem('shd-build-loadout-draft-v1');
saved = true;
toast.success('Draft saved locally', 'This loadout is available on this device.');
}
@@ -397,9 +382,6 @@
{#if activeParts[0] === 'specialization'}
<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"/>
{#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}
<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">
+4 -4
View File
@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit';
import { getDatabase } from '$lib/server/database';
import { getSessionUser } from '$lib/server/auth/session';
import { migrateLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
import type { PageServerLoad } from './$types';
@@ -19,9 +19,9 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found');
let loadout: LoadoutDraft;
try {
const migrated = migrateLoadoutDraft(JSON.parse(String(build.loadout_json)));
if (!migrated) error(404, 'Build data is unavailable');
loadout = migrated;
const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
if (!parsed) error(404, 'Build data is unavailable');
loadout = parsed;
} catch {
error(404, 'Build data is unavailable');
}
+4 -4
View File
@@ -6,7 +6,7 @@ import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { migrateLoadoutDraft } from '$lib/builder/loadout';
import { parseLoadoutDraft } from '$lib/builder/loadout';
const operators = new Set([
'base_damage', 'flat_add', 'additive_percent', 'multiplicative_percent',
@@ -250,8 +250,8 @@ export const actions: Actions = {
const snapshotText = String(form.get('loadoutSnapshot') ?? '').trim();
let snapshot: unknown;
try { snapshot = JSON.parse(snapshotText); } catch { return fail(400, { calibrationError: 'The loadout snapshot is not valid JSON.' }); }
const migrated = migrateLoadoutDraft(snapshot);
if (!migrated) return fail(400, { calibrationError: 'The loadout snapshot is not a recognized SHD.build loadout.' });
const parsedLoadout = parseLoadoutDraft(snapshot);
if (!parsedLoadout) return fail(400, { calibrationError: 'The loadout snapshot does not match the current SHD.build loadout format.' });
const observed = {
bodyHit: optionalNumber(form.get('bodyHit')),
criticalBodyHit: optionalNumber(form.get('criticalBodyHit')),
@@ -279,7 +279,7 @@ export const actions: Actions = {
if (id && !before) return fail(404, { calibrationError: 'That calibration case no longer exists.' });
const now = new Date().toISOString();
const values = [
name, status, JSON.stringify(migrated), Number(form.get('selectedWeapon') ?? 0),
name, status, JSON.stringify(parsedLoadout), Number(form.get('selectedWeapon') ?? 0),
clean(form.get('gameVersion')) || null, clean(form.get('observedAt')) || null,
clean(form.get('notes')).slice(0, 4000) || null,
observed.bodyHit, observed.criticalBodyHit, observed.headshot, observed.criticalHeadshot,
+16 -15
View File
@@ -46,12 +46,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const requestedFilter = url.searchParams.get('filter') ?? '';
const filter = ['active', 'archived', 'configured', 'assigned', 'unassigned'].includes(requestedFilter) ? requestedFilter : '';
const filter = ['active', 'archived', 'configured', 'assigned'].includes(requestedFilter) ? requestedFilter : '';
const { search, pattern } = getRegistrySearch(url);
const conditions = [
...(filter === 'active' ? ['sets.active=1'] : filter === 'archived' ? ['sets.active=0']
: filter === 'assigned' ? ['sets.specialization_ability_id IS NOT NULL']
: filter === 'unassigned' ? ['sets.specialization_ability_id IS NULL']
: filter === 'configured' ? ['EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)', 'EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id)'] : []),
...(search ? ["sets.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
];
@@ -62,7 +61,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
SUM(CASE WHEN specialization_ability_id IS NULL THEN 1 ELSE 0 END) AS unassigned,
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
FROM skill_chip_sets`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`)).get(...args),
@@ -111,9 +109,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
chipCount: Number(abilityChipCounts.find((item) => item.specialization_ability_id === ability.id)?.count ?? 0)
}))
];
const pendingSetRows = filter === 'archived' ? [] : pendingSets
const currentPendingSets = pendingSets.filter((set) =>
Boolean(set.abilityId)
&& Array.isArray(set.primaryStatIds)
&& Array.isArray(set.secondaryStatIds)
&& Array.isArray(set.triggerIds)
);
const pendingSetRows = filter === 'archived' ? [] : currentPendingSets
.filter((set) => filter !== 'assigned' || Boolean(set.abilityId))
.filter((set) => filter !== 'unassigned' || !set.abilityId)
.filter((set) => !search || String(set.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
.map((set) => ({
...set,
@@ -124,13 +127,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
min_value: Number(set.twoPieceMin),
max_value: Number(set.twoPieceMax)
},
primaryStatIds: Array.isArray(set.primaryStatIds) ? set.primaryStatIds : [set.primaryStatIds].filter(Boolean),
secondaryStatIds: Array.isArray(set.secondaryStatIds) ? set.secondaryStatIds : [set.secondaryStatIds].filter(Boolean),
primaryStatIds: set.primaryStatIds,
secondaryStatIds: set.secondaryStatIds,
talent: {
name: set.talentName,
description_template: set.descriptionTemplate,
effects: (() => { try { return JSON.parse(String(set.talentEffects ?? '[]')); } catch { return []; } })(),
triggerIds: Array.isArray(set.triggerIds) ? set.triggerIds : [set.triggerIds].filter(Boolean)
triggerIds: set.triggerIds
}
}));
return {
@@ -158,10 +161,9 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
active: Number(summary?.active ?? 0),
archived: Number(summary?.archived ?? 0),
configured: Number(summary?.configured ?? 0),
total: Number(summary?.total ?? 0) + pendingSets.length,
assigned: Number(summary?.assigned ?? 0) + pendingSets.filter((set) => Boolean(set.abilityId)).length,
unassigned: Number(summary?.unassigned ?? 0) + pendingSets.filter((set) => !set.abilityId).length,
pending: pendingSets.length
total: Number(summary?.total ?? 0) + currentPendingSets.length,
assigned: Number(summary?.assigned ?? 0) + currentPendingSets.length,
pending: currentPendingSets.length
},
filter, pagination, search
};
@@ -251,7 +253,7 @@ export const actions: Actions = {
if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' });
const database = await getDatabase();
await (await database.prepare('UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
await refreshAbilityFocuses(database, String(before.record.specialization_ability_id ?? ''));
await refreshAbilityFocuses(database, String(before.record.specialization_ability_id));
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'skill_chip.archived', entityId: id, before, after: await snapshot(id) });
return { skillChipArchived: true };
},
@@ -260,8 +262,7 @@ export const actions: Actions = {
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record) return fail(404, { skillChipError: 'Skill Chip set not found.' });
const abilityId = String(before.record.specialization_ability_id ?? '');
if (!abilityId) return fail(400, { skillChipError: 'Assign this legacy Skill Chip to an ability before restoring it.' });
const abilityId = String(before.record.specialization_ability_id);
const database = await getDatabase();
if (!await (await database.prepare('SELECT id FROM specialization_abilities WHERE id=? AND active=1')).get(abilityId)) {
return fail(400, { skillChipError: 'The assigned specialization ability must be active before restoring this Skill Chip.' });
+3 -3
View File
@@ -38,7 +38,7 @@
const edit = (item: any) => {
editing = {
...item,
abilityId: item.specialization_ability_id ?? '',
abilityId: String(item.specialization_ability_id),
twoPiece: item.twoPiece ? { ...item.twoPiece } : { stat_attribute_id: '', min_value: '', max_value: '' },
primaryStatIds: [...item.primaryStatIds],
secondaryStatIds: [...item.secondaryStatIds],
@@ -69,7 +69,7 @@
{value:'',label:'TOTAL SETS',count:data.summary.total,description:'Show all definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
{value:'assigned',label:'ABILITY LINKED',count:data.summary.assigned,description:'Assigned to a class ability'},
{value:'unassigned',label:'UNASSIGNED',count:data.summary.unassigned,description:'Legacy records needing an ability'},
{value:'configured',label:'CONFIGURED',count:data.summary.configured,description:'Complete 2-piece and 3-piece effects'},
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
]}/>
@@ -114,6 +114,6 @@
<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>
{#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}
</div>
+4 -3
View File
@@ -70,7 +70,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'slotType', 'isExotic', 'exoticWeaponId', 'compatibleCategories', 'qualityValues', 'secondaryRanges', 'effects', 'exoticAttributes'
] }])
]);
const pendingRows = filter === 'archived' ? [] : pendingMods.filter((item) =>
const currentPendingMods = pendingMods.filter((item) => Array.isArray(item.compatibleCategories));
const pendingRows = filter === 'archived' ? [] : currentPendingMods.filter((item) =>
(!search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) &&
(filter !== 'regular' || !item.exoticWeaponId) && (filter !== 'exotic' || Boolean(item.exoticWeaponId))
);
@@ -95,7 +96,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
slot_type: item.slotType,
exotic_weapon_id: item.exoticWeaponId,
exotic_weapon_name: availableWeapons.find((weapon) => weapon.id === item.exoticWeaponId)?.name ?? '',
compatibleCategories: Array.isArray(item.compatibleCategories) ? item.compatibleCategories : [item.compatibleCategories].filter(Boolean),
compatibleCategories: item.compatibleCategories,
qualityValues: parseJson(String(item.qualityValues), []),
secondaryRanges: parseJson(String(item.secondaryRanges), []),
exoticAttributes: parseJson(String(item.exoticAttributes), []),
@@ -128,7 +129,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
slots: slots.map((name) => ({ id: name, name })),
qualities,
weaponCategories: weaponCategories.map((name) => ({ id: name, name: name.toUpperCase() })),
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingMods.length, pending: pendingMods.length },
summary: { ...summary, total: Number(summary?.total ?? 0) + currentPendingMods.length, pending: currentPendingMods.length },
filter, pagination, search
};
};