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 { migrateLoadoutDraft } from '$lib/builder/loadout'; import type { Actions, PageServerLoad } from './$types'; 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 migrateLoadoutDraft(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, focusAbilities, specializationNodes, specializationRanks, specializationRankEffects, specializationRankCosts, specializationCombatEffects, specializationRankCombatEffects, specializationCombatModifiers, progression, rankProfiles, activeCalculationRevision, calculationStages, calculationProfiles ] = 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_base_attribute_bonuses ORDER BY 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_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_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(), (await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(), (await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).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(), (await database.prepare("SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1")).get(), (await database.prepare(`SELECT stage.* FROM calculation_stages stage JOIN calculation_revisions revision ON revision.id=stage.revision_id WHERE revision.status='active' ORDER BY stage.display_order`)).all(), (await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile JOIN calculation_revisions revision ON revision.id=profile.revision_id WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).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: 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)), baseBonuses: weaponBonuses.filter((bonus) => bonus.weapon_id === weapon.id) })), 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) } : 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)), combatEffects: specializationRankCombatEffects .filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)) .map((effect) => ({ ...effect, definition: specializationCombatEffects.find((definition) => definition.id === effect.combat_effect_id), modifiers: specializationCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id) })) })) })); return { ...focus, abilities: assignedAbilities, nodes }; }) })), specializationProgression: { ...progression, availablePoints: Number(progression?.xp_points ?? 30) + Number(progression?.shd_cache_points ?? 19), rankProfiles }, calculation: { revision: activeCalculationRevision, stages: calculationStages, profiles: calculationProfiles } }; }; 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 } }; } };