Files
shd.build/src/routes/builder/+page.server.ts
T
jb d09b6220ee
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 27s
Validate and deploy staging / deploy (push) Successful in 54s
feat: defer advanced build calculations
2026-08-02 03:07:12 -04:00

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