519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
import { getDatabase } from '$lib/server/database';
|
|
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 { parseLoadoutDraft } from '$lib/builder/loadout';
|
|
import type { Actions, PageServerLoad } from './$types';
|
|
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
|
|
|
const validVisibility = new Set(['private', 'unlisted', 'public']);
|
|
const slugify = (value: string) =>
|
|
value
|
|
.toLowerCase()
|
|
.normalize('NFKD')
|
|
.replace(/[^\w\s-]/g, '')
|
|
.trim()
|
|
.replace(/[\s_]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.slice(0, 60) || 'loadout';
|
|
|
|
function parseLoadout(raw: FormDataEntryValue | null) {
|
|
if (typeof raw !== 'string' || raw.length > 250_000) return null;
|
|
try {
|
|
return parseLoadoutDraft(JSON.parse(raw));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
|
const database = await getDatabase();
|
|
const viewer = await getSessionUser(cookies);
|
|
const requestedSlug = url.searchParams.get('build');
|
|
let initialBuild = null;
|
|
if (requestedSlug && viewer) {
|
|
const row = await (
|
|
await database.prepare(
|
|
'SELECT id,title,slug,visibility,loadout_json FROM builds WHERE slug=? AND owner_id=?',
|
|
)
|
|
).get(requestedSlug, viewer.id);
|
|
const loadout =
|
|
typeof row?.loadout_json === 'string'
|
|
? parseLoadout(row.loadout_json)
|
|
: null;
|
|
if (row && loadout)
|
|
initialBuild = {
|
|
id: String(row.id),
|
|
slug: String(row.slug),
|
|
visibility: String(row.visibility),
|
|
loadout,
|
|
};
|
|
}
|
|
const [
|
|
attributes,
|
|
gear,
|
|
gearBrands,
|
|
gearTalents,
|
|
gearAugments,
|
|
gearAugmentCosts,
|
|
brands,
|
|
brandEffects,
|
|
weapons,
|
|
weaponRanges,
|
|
weaponTalentOptions,
|
|
weaponBonuses,
|
|
talents,
|
|
talentTiers,
|
|
talentEffects,
|
|
talentTemplates,
|
|
chipSets,
|
|
chipTwoPiece,
|
|
chipPrimary,
|
|
chipSecondary,
|
|
chipTalents,
|
|
chipTalentEffects,
|
|
protocols,
|
|
protocolPrimary,
|
|
protocolProfiles,
|
|
protocolSecondary,
|
|
protocolTalents,
|
|
protocolTalentEffects,
|
|
coreRanges,
|
|
icons,
|
|
specializations,
|
|
specializationFocuses,
|
|
specializationAbilities,
|
|
specializationAbilityAttacks,
|
|
focusAbilities,
|
|
specializationNodes,
|
|
specializationRanks,
|
|
specializationRankEffects,
|
|
specializationRankCosts,
|
|
progression,
|
|
rankProfiles,
|
|
] = await Promise.all([
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(await database.prepare('SELECT * FROM gear_brand_sets')).all(),
|
|
(await database.prepare('SELECT * FROM gear_talents')).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM brand_set_effects ORDER BY pieces_required',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM weapon_talent_options ORDER BY talent_slot',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
|
|
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
|
|
(
|
|
await database.prepare('SELECT * FROM talent_description_templates')
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
|
).all(),
|
|
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
|
(
|
|
await database.prepare('SELECT * FROM skill_chip_secondary_options')
|
|
).all(),
|
|
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
|
(
|
|
await database.prepare('SELECT * FROM os_protocol_secondary_profiles')
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
|
|
)
|
|
).all(),
|
|
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM core_attribute_tier_ranges ORDER BY tier',
|
|
)
|
|
).all(),
|
|
(await database.prepare('SELECT * FROM game_data_icons')).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare('SELECT * FROM specialization_ability_attacks')
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
"SELECT * FROM specialization_progression_config WHERE id='default'",
|
|
)
|
|
).get(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
|
|
)
|
|
).all(),
|
|
]);
|
|
const iconUrl = (
|
|
type:
|
|
| 'talent'
|
|
| 'brand_set'
|
|
| 'os_protocol'
|
|
| 'skill_chip'
|
|
| 'specialization'
|
|
| 'specialization_ability'
|
|
| 'specialization_node',
|
|
id: unknown,
|
|
) => {
|
|
const icon = icons.find(
|
|
(item) => item.entity_type === type && item.entity_id === id,
|
|
);
|
|
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
|
|
};
|
|
const configuredTalents = talents.map((talent) => ({
|
|
...talent,
|
|
iconUrl: iconUrl('talent', talent.id),
|
|
template:
|
|
talentTemplates.find((item) => item.talent_id === talent.id)?.template ??
|
|
'',
|
|
tiers: talentTiers
|
|
.filter((tier) => tier.talent_id === talent.id)
|
|
.map((tier) => ({
|
|
...tier,
|
|
effects: talentEffects.filter(
|
|
(effect) => effect.talent_tier_id === tier.id,
|
|
),
|
|
})),
|
|
}));
|
|
return {
|
|
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
|
|
initialBuild,
|
|
attributes,
|
|
coreRanges,
|
|
gear: gear.map((item) => ({
|
|
...item,
|
|
brandIds: gearBrands
|
|
.filter((link) => link.gear_id === item.id)
|
|
.map((link) => String(link.brand_set_id)),
|
|
talentIds: gearTalents
|
|
.filter((link) => link.gear_id === item.id)
|
|
.map((link) => String(link.talent_id)),
|
|
})),
|
|
gearAugments: gearAugments.map((augment) => ({
|
|
...augment,
|
|
costs: gearAugmentCosts.filter((cost) => cost.augment_id === augment.id),
|
|
})),
|
|
brands: brands.map((brand) => ({
|
|
...brand,
|
|
iconUrl: iconUrl('brand_set', brand.id),
|
|
effects: brandEffects.filter(
|
|
(effect) => effect.brand_set_id === brand.id,
|
|
),
|
|
})),
|
|
weapons: attachWeaponCategoryBonuses(
|
|
weapons.map((weapon) => ({
|
|
...weapon,
|
|
ranges: weaponRanges.filter((range) => range.weapon_id === weapon.id),
|
|
talent1Ids: weaponTalentOptions
|
|
.filter(
|
|
(link) =>
|
|
link.weapon_id === weapon.id && Number(link.talent_slot) === 1,
|
|
)
|
|
.map((link) => String(link.talent_id)),
|
|
talent2Ids: weaponTalentOptions
|
|
.filter(
|
|
(link) =>
|
|
link.weapon_id === weapon.id && Number(link.talent_slot) === 2,
|
|
)
|
|
.map((link) => String(link.talent_id)),
|
|
})),
|
|
weaponBonuses,
|
|
),
|
|
talents: configuredTalents,
|
|
chipSets: chipSets.map((set) => {
|
|
const talent = chipTalents.find(
|
|
(item) => item.skill_chip_set_id === set.id,
|
|
);
|
|
return {
|
|
...set,
|
|
iconUrl: iconUrl('skill_chip', set.id),
|
|
twoPiece:
|
|
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
|
null,
|
|
primaryStatIds: chipPrimary
|
|
.filter((item) => item.skill_chip_set_id === set.id)
|
|
.map((item) => String(item.stat_attribute_id)),
|
|
secondaryStatIds: chipSecondary
|
|
.filter((item) => item.skill_chip_set_id === set.id)
|
|
.map((item) => String(item.stat_attribute_id)),
|
|
talent: talent
|
|
? {
|
|
...talent,
|
|
effects: chipTalentEffects.filter(
|
|
(effect) => effect.talent_id === talent.id,
|
|
),
|
|
}
|
|
: null,
|
|
};
|
|
}),
|
|
protocols: protocols.map((protocol) => {
|
|
const profile = protocolProfiles.find(
|
|
(item) =>
|
|
item.quality === protocol.quality &&
|
|
item.primary_attribute_type === protocol.primary_attribute_type,
|
|
);
|
|
const talent = protocolTalents.find(
|
|
(item) => item.protocol_id === protocol.id,
|
|
);
|
|
return {
|
|
...protocol,
|
|
iconUrl: iconUrl('os_protocol', protocol.id),
|
|
primaryEffect:
|
|
protocolPrimary.find((item) => item.protocol_id === protocol.id) ??
|
|
null,
|
|
secondaryEffects: profile
|
|
? protocolSecondary.filter(
|
|
(effect) => effect.profile_id === profile.id,
|
|
)
|
|
: [],
|
|
talent: talent
|
|
? {
|
|
...talent,
|
|
effects: protocolTalentEffects.filter(
|
|
(effect) => effect.talent_id === talent.id,
|
|
),
|
|
}
|
|
: null,
|
|
};
|
|
}),
|
|
specializations: specializations.map((specialization) => ({
|
|
...specialization,
|
|
iconUrl: iconUrl('specialization', specialization.id),
|
|
focuses: specializationFocuses
|
|
.filter((focus) => focus.specialization_id === specialization.id)
|
|
.map((focus) => {
|
|
const assignedAbilities = focusAbilities
|
|
.filter((link) => link.focus_id === focus.id)
|
|
.sort(
|
|
(left, right) =>
|
|
Number(left.ability_slot) - Number(right.ability_slot),
|
|
)
|
|
.map((link) => {
|
|
const ability = specializationAbilities.find(
|
|
(item) => item.id === link.ability_id,
|
|
);
|
|
return ability
|
|
? {
|
|
...ability,
|
|
slot: Number(link.ability_slot),
|
|
iconUrl: iconUrl('specialization_ability', ability.id),
|
|
attack:
|
|
specializationAbilityAttacks.find(
|
|
(attack) => attack.ability_id === ability.id,
|
|
) ?? null,
|
|
}
|
|
: null;
|
|
})
|
|
.filter(Boolean);
|
|
const nodes = specializationNodes
|
|
.filter((node) => node.focus_id === focus.id)
|
|
.map((node) => ({
|
|
...node,
|
|
iconUrl: iconUrl('specialization_node', node.id),
|
|
ranks: specializationRanks
|
|
.filter((rank) => rank.node_id === node.id)
|
|
.map((rank) => ({
|
|
...rank,
|
|
effects: specializationRankEffects.filter(
|
|
(effect) =>
|
|
effect.node_id === node.id &&
|
|
Number(effect.rank) === Number(rank.rank),
|
|
),
|
|
costs: specializationRankCosts.filter(
|
|
(cost) =>
|
|
cost.node_id === node.id &&
|
|
Number(cost.rank) === Number(rank.rank),
|
|
),
|
|
})),
|
|
}));
|
|
return { ...focus, abilities: assignedAbilities, nodes };
|
|
}),
|
|
})),
|
|
specializationProgression: {
|
|
...progression,
|
|
availablePoints:
|
|
Number(progression?.xp_points ?? 30) +
|
|
Number(progression?.shd_cache_points ?? 19),
|
|
rankProfiles,
|
|
},
|
|
};
|
|
};
|
|
|
|
export const actions: Actions = {
|
|
save: async ({ cookies, request }) => {
|
|
const user = await getSessionUser(cookies);
|
|
if (!user)
|
|
return fail(401, {
|
|
buildError: 'Sign in to save and share this loadout.',
|
|
});
|
|
const formData = await request.formData();
|
|
const loadout = parseLoadout(formData.get('loadout'));
|
|
const visibility = String(formData.get('visibility') ?? 'private');
|
|
const id = String(formData.get('id') ?? '').trim();
|
|
if (!loadout)
|
|
return fail(400, {
|
|
buildError:
|
|
'The loadout data is invalid. Refresh the builder and try again.',
|
|
});
|
|
const title = loadout.buildName.trim();
|
|
if (title.length < 2 || title.length > 100)
|
|
return fail(400, {
|
|
buildError: 'Build names must be between 2 and 100 characters.',
|
|
});
|
|
if (!validVisibility.has(visibility))
|
|
return fail(400, { buildError: 'Choose a valid build visibility.' });
|
|
|
|
const database = await getDatabase();
|
|
const now = new Date().toISOString();
|
|
let buildId = id;
|
|
let slug = '';
|
|
if (buildId) {
|
|
const existing = await (
|
|
await database.prepare(
|
|
'SELECT id,slug FROM builds WHERE id=? AND owner_id=?',
|
|
)
|
|
).get(buildId, user.id);
|
|
if (!existing)
|
|
return fail(404, {
|
|
buildError: 'This saved build could not be found.',
|
|
});
|
|
slug = String(existing.slug);
|
|
await (
|
|
await database.prepare(
|
|
'UPDATE builds SET title=?,visibility=?,loadout_json=?,updated_at=? WHERE id=? AND owner_id=?',
|
|
)
|
|
).run(title, visibility, JSON.stringify(loadout), now, buildId, user.id);
|
|
} else {
|
|
buildId = randomUUID();
|
|
slug = `${slugify(title)}-${buildId.slice(0, 8)}`;
|
|
await (
|
|
await database.prepare(
|
|
`INSERT INTO builds(id,owner_id,title,slug,visibility,loadout_json,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?)`,
|
|
)
|
|
).run(
|
|
buildId,
|
|
user.id,
|
|
title,
|
|
slug,
|
|
visibility,
|
|
JSON.stringify(loadout),
|
|
now,
|
|
now,
|
|
);
|
|
}
|
|
return {
|
|
buildSaved: true,
|
|
buildToken: randomUUID(),
|
|
savedBuild: { id: buildId, slug, visibility, title },
|
|
};
|
|
},
|
|
};
|