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