feat: add community publishing and news syndication
This commit is contained in:
+608
-260
@@ -4,280 +4,628 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
||||
import { normalizeUserMarkdown } from '$lib/markdown';
|
||||
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';
|
||||
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;
|
||||
}
|
||||
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,summary,guide_body,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),
|
||||
summary: String(row.summary ?? ''),
|
||||
guideBody: String(row.guide_body ?? ''),
|
||||
loadout
|
||||
};
|
||||
}
|
||||
const [
|
||||
attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects,
|
||||
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
|
||||
talents, talentTiers, talentEffects, talentTemplates, conditionalRules, conditionalPools, conditionalClauses, conditionalOutcomes,
|
||||
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
|
||||
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects, coreRanges,
|
||||
icons, specializations, specializationFocuses, specializationAbilities, specializationAbilityAttacks, focusAbilities,
|
||||
specializationNodes, specializationRanks, specializationRankEffects, specializationRankCosts,
|
||||
specializationCombatEffects, specializationRankCombatEffects, specializationCombatModifiers,
|
||||
progression, rankProfiles, activeCalculationRevision, calculationStages, calculationProfiles, skillCalculationProfiles
|
||||
] = 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 conditional_effect_rules WHERE configured=1')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order')).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_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
|
||||
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,
|
||||
conditionalRules,
|
||||
conditionalPools,
|
||||
conditionalClauses,
|
||||
conditionalOutcomes,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipPrimary,
|
||||
chipSecondary,
|
||||
chipTalents,
|
||||
chipTalentEffects,
|
||||
protocols,
|
||||
protocolPrimary,
|
||||
protocolProfiles,
|
||||
protocolSecondary,
|
||||
protocolTalents,
|
||||
protocolTalentEffects,
|
||||
coreRanges,
|
||||
icons,
|
||||
specializations,
|
||||
specializationFocuses,
|
||||
specializationAbilities,
|
||||
specializationAbilityAttacks,
|
||||
focusAbilities,
|
||||
specializationNodes,
|
||||
specializationRanks,
|
||||
specializationRankEffects,
|
||||
specializationRankCosts,
|
||||
specializationCombatEffects,
|
||||
specializationRankCombatEffects,
|
||||
specializationCombatModifiers,
|
||||
progression,
|
||||
rankProfiles,
|
||||
activeCalculationRevision,
|
||||
calculationStages,
|
||||
calculationProfiles,
|
||||
skillCalculationProfiles,
|
||||
] = 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 conditional_effect_rules WHERE configured=1',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order',
|
||||
)
|
||||
).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_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
|
||||
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(),
|
||||
(await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT profile.* FROM skill_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 hydrateRule = (sourceType: string, sourceId: unknown) => {
|
||||
const rule = conditionalRules.find((item) => item.source_type === sourceType && item.source_id === sourceId);
|
||||
return rule ? {
|
||||
rule,
|
||||
statePools: conditionalPools.filter((pool) => pool.rule_id === rule.id),
|
||||
clauses: conditionalClauses.filter((clause) => clause.rule_id === rule.id),
|
||||
outcomes: conditionalOutcomes.filter((outcome) => outcome.rule_id === rule.id),
|
||||
triggers: []
|
||||
} : null;
|
||||
};
|
||||
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)
|
||||
})),
|
||||
rule: hydrateRule('talent', talent.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),
|
||||
rule: hydrateRule('skill_chip_talent', 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),
|
||||
rule: hydrateRule('os_protocol_talent', 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)),
|
||||
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),
|
||||
rule: hydrateRule('specialization_tree_rank', 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,
|
||||
skillProfiles: skillCalculationProfiles
|
||||
}
|
||||
};
|
||||
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 hydrateRule = (sourceType: string, sourceId: unknown) => {
|
||||
const rule = conditionalRules.find(
|
||||
(item) => item.source_type === sourceType && item.source_id === sourceId,
|
||||
);
|
||||
return rule
|
||||
? {
|
||||
rule,
|
||||
statePools: conditionalPools.filter(
|
||||
(pool) => pool.rule_id === rule.id,
|
||||
),
|
||||
clauses: conditionalClauses.filter(
|
||||
(clause) => clause.rule_id === rule.id,
|
||||
),
|
||||
outcomes: conditionalOutcomes.filter(
|
||||
(outcome) => outcome.rule_id === rule.id,
|
||||
),
|
||||
triggers: [],
|
||||
}
|
||||
: null;
|
||||
};
|
||||
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,
|
||||
),
|
||||
})),
|
||||
rule: hydrateRule('talent', talent.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,
|
||||
),
|
||||
rule: hydrateRule('skill_chip_talent', 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,
|
||||
),
|
||||
rule: hydrateRule('os_protocol_talent', 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),
|
||||
),
|
||||
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,
|
||||
),
|
||||
rule: hydrateRule('specialization_tree_rank', 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,
|
||||
skillProfiles: skillCalculationProfiles,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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();
|
||||
const summary = normalizeUserMarkdown(formData.get('summary'), 500);
|
||||
const guideBody = normalizeUserMarkdown(formData.get('guideBody'), 12_000);
|
||||
if (!loadout) return fail(400, { buildError: 'The loadout data is invalid. Refresh the builder and try again.' });
|
||||
if (summary === null || guideBody === null) return fail(400, { buildError: 'Build notes contain unsupported characters or exceed their length limit.' });
|
||||
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.' });
|
||||
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=?,summary=?,guide_body=?,loadout_json=?,updated_at=? WHERE id=? AND owner_id=?'
|
||||
)).run(title, visibility, summary, guideBody, 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,summary,guide_body,loadout_json,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`
|
||||
)).run(buildId, user.id, title, slug, visibility, summary, guideBody, JSON.stringify(loadout), now, now);
|
||||
}
|
||||
return {
|
||||
buildSaved: true,
|
||||
buildToken: randomUUID(),
|
||||
savedBuild: { id: buildId, slug, visibility, title }
|
||||
};
|
||||
}
|
||||
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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
|
||||
import CombatAssumptions from '$lib/components/builder/CombatAssumptions.svelte';
|
||||
import ConditionalEffectCard from '$lib/components/builder/ConditionalEffectCard.svelte';
|
||||
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
||||
@@ -27,9 +26,6 @@
|
||||
let active = $state('specialization:focus');
|
||||
let saved = $state(false);
|
||||
let message = $state('');
|
||||
let buildSummary = $state(untrack(() => data.initialBuild?.summary ?? ''));
|
||||
let buildGuide = $state(untrack(() => data.initialBuild?.guideBody ?? ''));
|
||||
let buildNotesOpen = $state(untrack(() => Boolean(data.initialBuild?.summary || data.initialBuild?.guideBody)));
|
||||
let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? ''));
|
||||
let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? ''));
|
||||
let persistedVisibility = $state(untrack(() => data.initialBuild?.visibility ?? 'private'));
|
||||
@@ -501,9 +497,6 @@
|
||||
onMount(() => {
|
||||
if (data.initialBuild) return;
|
||||
localStorage.removeItem('shd-build-loadout-draft-v3');
|
||||
buildSummary = localStorage.getItem('shd-build-summary-v1') ?? '';
|
||||
buildGuide = localStorage.getItem('shd-build-guide-v1') ?? '';
|
||||
buildNotesOpen = Boolean(buildSummary || buildGuide);
|
||||
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
|
||||
if (!stored) return;
|
||||
try {
|
||||
@@ -531,8 +524,6 @@
|
||||
});
|
||||
function saveDraft() {
|
||||
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
|
||||
localStorage.setItem('shd-build-summary-v1', buildSummary);
|
||||
localStorage.setItem('shd-build-guide-v1', buildGuide);
|
||||
saved = true;
|
||||
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
||||
}
|
||||
@@ -545,15 +536,12 @@
|
||||
<div><p class="eyebrow"><span></span> COMMAND GRID / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
|
||||
<div class="actions">
|
||||
<span class:visible={saved}>SAVED LOCALLY</span>
|
||||
<button class="secondary" type="button" aria-expanded={buildNotesOpen} onclick={() => buildNotesOpen = !buildNotesOpen}>BUILD NOTES</button>
|
||||
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button>
|
||||
{#if data.viewer}
|
||||
<PendingForm action="?/save" class="builder-save-form">
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="id" value={persistedBuildId}/>
|
||||
<input type="hidden" name="loadout" value={JSON.stringify(draft)}/>
|
||||
<input type="hidden" name="summary" value={buildSummary}/>
|
||||
<input type="hidden" name="guideBody" value={buildGuide}/>
|
||||
<button class="primary" type="submit" name="visibility" value={persistedVisibility} aria-label="Save build to account">{pending ? 'SAVING…' : 'SAVE TO ACCOUNT'}</button>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
@@ -573,12 +561,6 @@
|
||||
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if buildNotesOpen}
|
||||
<section class="builder-notes" aria-label="Build sharing notes">
|
||||
<label for="builder-build-summary"><span>STRENGTHS / SUMMARY <small>OPTIONAL · SHOWN WHEN SHARED</small></span><textarea id="builder-build-summary" name="builder-build-summary" bind:value={buildSummary} maxlength="500" rows="3" placeholder="What this build does well and where it fits."></textarea></label>
|
||||
<MarkdownEditor bind:value={buildGuide} name="builder-guide-editor" label="HOW TO USE THIS BUILD" help="Add an operating sequence, positioning advice, and important tradeoffs for players who open the shared build." maxLength={12000} rows={7}/>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
|
||||
</div>
|
||||
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||
|
||||
Reference in New Issue
Block a user