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 }, }; }, };