Files
shd.build/src/routes/builder/+page.svelte
T

783 lines
57 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, untrack } from 'svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import BuilderPicker from '$lib/components/BuilderPicker.svelte';
import BuilderRadioGrid from '$lib/components/BuilderRadioGrid.svelte';
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
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';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
import SkillCalculationResult from '$lib/components/builder/SkillCalculationResult.svelte';
import { evaluateLoadoutCombat, evaluateLoadoutSkillCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
import { toast } from '$lib/toast';
import { MECHANIC_EVENT_REGISTRY, type MechanicEventKind } from '$lib/calculations/mechanic';
import {
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, parseLoadoutDraft, remapRangeValue, scaled, weaponBaseRange,
type LoadoutDraft, type Roll
} from '$lib/builder/loadout';
let { data, form } = $props();
let draft = $state<LoadoutDraft>(untrack(() => parseLoadoutDraft(data.initialBuild?.loadout) ?? createDraft()));
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'));
let handledBuildToken = '';
const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
const coreKeys = ['firepower','toughness','engineering'] as const;
const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const weaponAttributes = $derived(data.attributes.filter((item) => item.weapon_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const chipAttributes = $derived(data.attributes.filter((item) => item.skill_chip_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
const attribute = (id: string) => data.attributes.find((item) => item.id === id);
const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
const weaponItem = (index: number) => data.weapons.find((item) => item.id === draft.weapons[index].itemId);
const chipItem = (index: number) => data.chipSets.find((item) => item.id === draft.chips[index].setId);
const protocolItem = () => data.protocols.find((item) => item.id === draft.protocol.itemId);
const allFocuses = $derived(data.specializations.flatMap((specialization: any) =>
specialization.focuses.map((focus: any) => ({ ...focus, specialization }))
));
const selectedFocus = $derived(allFocuses.find((focus: any) => focus.id === draft.specialization.focusId));
const specializationItems = $derived(allFocuses.map((focus: any) => ({
id: focus.id,
name: `${focus.specialization.name} · ${focus.name}`,
category: `${focus.primary_attribute} · ${Number(focus.builder_ready) === 1 ? 'BUILDER READY' : 'INCOMPLETE DATA'}`,
iconUrl: focus.specialization.iconUrl
})));
const selectedNodeRanks = $derived(selectedFocus?.nodes.flatMap((node: any) => {
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
return selectedRank ? [{ node, rank: node.ranks.find((entry: any) => Number(entry.rank) === selectedRank) }] : [];
}) ?? []);
const selectedCombatEffects = $derived(selectedNodeRanks.flatMap(({ node, rank }: any) =>
(rank?.combatEffects ?? [])
.filter((effect: any) => Number(effect.rule?.configured) === 1)
.map((effect: any) => ({ ...effect, nodeName: node.name }))
));
const talent = (id: string) => data.talents.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
const unitValue = (value: unknown, statId: string) => {
const numeric = Number(value);
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = attribute(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const jsonObject = (value: unknown) => {
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, any>;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, any>
: {};
} catch {
return {};
}
};
const mechanicScenarioInputs = (rule: any) => {
const raw = rule?.rule?.scenario_inputs_json;
if (Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const combatEffectDescription = (effect: any) => {
const template = String(effect.definition?.description_template ?? effect.definition?.name ?? 'Conditional effect');
const rule = effect.rule;
const outcomes = (rule?.outcomes ?? []).filter((outcome: any) => outcome.variant_key === 'base');
const firstReducer = jsonObject(rule?.clauses?.[0]?.reducer_json);
const primaryPool = rule?.statePools?.[0];
return template
.replaceAll('{triggerCount}', String(firstReducer.threshold ?? ''))
.replaceAll('{triggerWindow}', firstReducer.windowSeconds ? `${firstReducer.windowSeconds}s` : '')
.replaceAll('{duration}', String(primaryPool?.duration_seconds ?? 0))
.replaceAll('{maxStacks}', String(primaryPool?.maximum_value ?? 1))
.replaceAll('{effectName}', String(effect.definition?.name ?? 'Effect'))
.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
const outcome = outcomes[Number(raw) - 1];
return outcome?.stat_attribute_id
? unitValue(outcome.value, outcome.stat_attribute_id)
: placeholder;
});
};
const conditionalFacts = (rule: any) => {
const clauses = rule?.clauses ?? [];
const eventLabels = [...new Set(clauses.map((clause: any) => {
const event = String(clause.event_kind ?? '') as MechanicEventKind;
return MECHANIC_EVENT_REGISTRY[event]?.label;
}).filter(Boolean))];
const readiness = String(rule?.rule?.simulation_readiness ?? 'captured_only')
.replaceAll('_', ' ')
.toUpperCase();
const cooldowns = clauses
.map((clause: any) => Number(clause.cooldown_seconds ?? 0))
.filter((value: number) => value > 0);
const poolMaximum = Math.max(0, ...(rule?.statePools ?? [])
.map((pool: any) => Number(pool.maximum_value ?? 0)));
return [
`${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'}`,
eventLabels.length ? eventLabels.join(' · ').toUpperCase() : 'NO EVENT BRANCHES',
poolMaximum > 1 ? `MAX ${poolMaximum} STACKS` : readiness,
cooldowns.length ? `${Math.max(...cooldowns)}s COOLDOWN` : 'NO COOLDOWN'
];
};
const scaledRange = (minimum: unknown, maximum: unknown, level: unknown, referenceLevel: unknown, source: 'gear' | 'weapon') =>
levelScaledRange(minimum, maximum, level, referenceLevel ?? 50, source);
const gearBaseRange = (item: any, key: 'health' | 'armor', level: number) =>
gearBaseStatRange(item?.[`base_${key}_min`], item?.[`base_${key}_max`], level);
const weaponStatRange = (range: any, key: typeof weaponRangeKeys[number], level: number) =>
key === 'damage'
? weaponBaseRange(range?.damage_min, range?.damage_max, level)
: scaledRange(range?.[`${key}_min`], range?.[`${key}_max`], level, range?.reference_level ?? 50, 'weapon');
const coreAllocationRange = (tier: number, level: number, source: 'gear' | 'weapon') => {
const profile = data.coreRanges.find((item) => Number(item.tier) === Number(tier));
const nominal = scaledRange(profile?.nominal_min, profile?.nominal_max, level, profile?.reference_level ?? 50, source);
return { ...nominal, poolMin: nominal.min * 3, poolMax: nominal.max * 3 };
};
const coreTotal = (selection: { firepower: number; toughness: number; engineering: number }) =>
coreKeys.reduce((total, key) => total + Number(selection[key] ?? 0), 0);
const attributeRange = (stat: any, source: 'gear' | 'weapon', level: number) =>
scaledRange(stat?.[`${source}_min`], stat?.[`${source}_max`], level, stat?.[`${source}_reference_level`], source);
const setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip', level = 50) => {
roll.statId = id;
const stat = attribute(id);
const min = source === 'chip'
? stat?.skill_chip_min
: attributeRange(stat, source, level).min;
roll.value = Number(min ?? 0);
};
const rankPointCost = (rank: number) => Number(data.specializationProgression.rankProfiles.find((profile: any) => Number(profile.rank) === rank)?.point_cost ?? rank);
const selectedSpecializationPoints = $derived(selectedFocus?.nodes.reduce((total: number, node: any) => {
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
return total + Array.from({ length: selectedRank }, (_, index) => rankPointCost(index + 1)).reduce((sum, value) => sum + value, 0);
}, 0) ?? 0);
const selectedSpecializationCosts = $derived.by(() => {
let credits = 0;
const resources = new Map<string, number>();
for (const { node, rank } of selectedNodeRanks) {
for (let current = 1; current <= Number(rank.rank); current += 1) {
const profile = data.specializationProgression.rankProfiles.find((item: any) => Number(item.rank) === current);
credits += Number(profile?.credit_cost ?? 0);
const rankRecord = node.ranks.find((item: any) => Number(item.rank) === current);
for (const cost of rankRecord?.costs ?? []) resources.set(String(cost.resource_id), (resources.get(String(cost.resource_id)) ?? 0) + Number(cost.quantity));
}
}
return { credits, resources: [...resources] };
});
const canSelectNodeRank = (node: any, rank: number) => {
if (rank === 0) return true;
const pointsWithoutNode = selectedSpecializationPoints - Array.from(
{ length: Number(draft.specialization.nodeRanks[node.id] ?? 0) },
(_, index) => rankPointCost(index + 1)
).reduce((sum, value) => sum + value, 0);
const candidatePoints = Array.from({ length: rank }, (_, index) => rankPointCost(index + 1)).reduce((sum, value) => sum + value, 0);
if (pointsWithoutNode + candidatePoints > Number(data.specializationProgression.availablePoints)) return false;
if (Number(node.row_number) <= 1) return true;
return selectedFocus.nodes
.filter((previous: any) => Number(previous.row_number) < Number(node.row_number))
.every((previous: any) => Number(draft.specialization.nodeRanks[previous.id] ?? 0) >= Math.min(2, Number(previous.max_rank)));
};
const setNodeRank = (node: any, rank: number) => {
if (!canSelectNodeRank(node, rank)) return;
draft.specialization.nodeRanks = { ...draft.specialization.nodeRanks, [node.id]: rank };
};
const selectFocus = (focusId: string) => {
const next = allFocuses.find((focus: any) => focus.id === focusId);
if (!next) return;
const displaced: LoadoutDraft['chips'][number][] = [];
for (let index = 0; index < draft.chips.length; index += 1) {
const chip = draft.chips[index];
const ability = next.abilities[Math.floor(index / 3)];
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
if (chip.setId && (!ability || definition?.specialization_ability_id !== ability.id)) {
displaced.push(structuredClone(chip));
draft.chips[index] = createDraft().chips[index];
}
}
for (const chip of displaced) {
const definition = data.chipSets.find((item: any) => item.id === chip.setId);
const abilityIndex = next.abilities.findIndex((ability: any) => ability.id === definition?.specialization_ability_id);
if (abilityIndex < 0) continue;
const targetIndex = [0, 1, 2].map((slot) => abilityIndex * 3 + slot).find((slot) => !draft.chips[slot].setId);
if (targetIndex === undefined) continue;
draft.chips[targetIndex] = structuredClone(chip);
}
draft.specialization = {
...draft.specialization,
focusId,
nodeRanks: {}
};
active = 'specialization:focus';
};
const toggleTalent = (key: string) => {
const current = draft.conditionalEffects[key]?.mode ?? 'off';
draft.conditionalEffects = {
...draft.conditionalEffects,
[key]: { mode: current === 'off' ? 'peak' : 'off' }
};
};
const setSkillActivation = (abilityId: string, patch: Record<string, unknown>) => {
const current = draft.skillActivations[abilityId] ?? { mode: 'off', targets: 1 };
draft.skillActivations = {
...draft.skillActivations,
[abilityId]: { ...current, ...patch }
};
};
const talentTier = (talentId: string, tier: number) => {
const item = talent(talentId);
return item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ?? item?.tiers[0];
};
const talentDescription = (talentId: string, tier: number) => {
const item = talent(talentId), configured = talentTier(talentId, tier);
if (!item || !configured) return '';
const template = item.template || configured.description;
return template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
const effect = configured.effects[Number(raw) - 1];
return effect ? unitValue(effect.value, effect.stat_attribute_id) : placeholder;
});
};
const configureGear = (slot: string, id: string) => {
const selection = draft.gear[slot], item = data.gear.find((entry) => entry.id === id);
selection.itemId = id;
selection.level = 50;
selection.brandId = item?.brandIds[0] ?? '';
selection.talentId = item?.talentIds[0] ?? '';
selection.health = Number(item?.base_health_min ?? 0);
selection.armor = Number(item?.base_armor_min ?? 0);
const core = coreAllocationRange(Number(item?.tier ?? 0), selection.level, 'gear');
for (const key of coreKeys) selection[key] = core.min;
selection.rolls = [blankRoll(), blankRoll()];
selection.augmentSlots = [];
};
const changeGearLevel = (slot: string, rawLevel: unknown) => {
const selection = draft.gear[slot], item = gearItem(slot);
const nextLevel = Math.round(clamp(rawLevel, Number(item?.tier) === 2 ? 40 : 1, 50));
if (!item || nextLevel === selection.level) {
selection.level = nextLevel;
return;
}
selection.health = remapRangeValue(
selection.health,
gearBaseRange(item, 'health', selection.level),
gearBaseRange(item, 'health', nextLevel)
);
if (item.base_armor_min !== null) selection.armor = remapRangeValue(
selection.armor,
gearBaseRange(item, 'armor', selection.level),
gearBaseRange(item, 'armor', nextLevel)
);
const previousCore = coreAllocationRange(Number(item.tier), selection.level, 'gear');
const nextCore = coreAllocationRange(Number(item.tier), nextLevel, 'gear');
for (const key of coreKeys) selection[key] = remapRangeValue(selection[key], { ...previousCore, min: 0, max: previousCore.poolMax }, { ...nextCore, min: 0, max: nextCore.poolMax });
for (const roll of selection.rolls) {
const stat = attribute(roll.statId);
if (!stat) continue;
roll.value = remapRangeValue(
roll.value,
attributeRange(stat, 'gear', selection.level),
attributeRange(stat, 'gear', nextLevel)
);
}
selection.level = nextLevel;
};
const configureWeapon = (index: number, id: string) => {
const selection = draft.weapons[index], item = data.weapons.find((entry) => entry.id === id);
selection.itemId = id;
selection.level = 50;
selection.tier = Number(item?.native_tier ?? 0);
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier);
selection.damage = weaponStatRange(range, 'damage', selection.level).min;
const core = coreAllocationRange(selection.tier, selection.level, 'weapon');
for (const key of coreKeys) selection[key] = core.min;
selection.talent1Id = item?.talent1Ids[0] ?? '';
selection.talent2Id = item?.talent2Ids[0] ?? '';
selection.rolls = [blankRoll(), blankRoll()];
};
const configureChip = (index: number, id: string) => {
const chip = draft.chips[index], item = data.chipSets.find((entry) => entry.id === id);
chip.setId = id;
chip.primary = { statId: item?.primaryStatIds[0] ?? '', value: 0 };
chip.secondary = [
{ statId: item?.secondaryStatIds[0] ?? '', value: 0 },
{ statId: item?.secondaryStatIds[1] ?? '', value: 0 }
];
if (chip.primary.statId) setRollStat(chip.primary, chip.primary.statId, 'chip');
for (const roll of chip.secondary) if (roll.statId) setRollStat(roll, roll.statId, 'chip');
};
const changeWeaponTier = (index: number, tier: number) => {
const selection = draft.weapons[index], item = weaponItem(index);
selection.tier = tier;
if (tier === 2 && selection.level < 40) selection.level = 40;
const range = item?.ranges.find((entry: any) => Number(entry.tier) === tier);
selection.damage = weaponStatRange(range, 'damage', selection.level).min;
const core = coreAllocationRange(tier, selection.level, 'weapon');
for (const key of coreKeys) selection[key] = core.min;
};
const changeWeaponLevel = (index: number, rawLevel: unknown) => {
const selection = draft.weapons[index], item = weaponItem(index);
const nextLevel = Math.round(clamp(rawLevel, selection.tier === 2 ? 40 : 1, 50));
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier);
if (!range || nextLevel === selection.level) {
selection.level = nextLevel;
return;
}
selection.damage = remapRangeValue(selection.damage, weaponStatRange(range, 'damage', selection.level), weaponStatRange(range, 'damage', nextLevel));
const previousCore = coreAllocationRange(selection.tier, selection.level, 'weapon');
const nextCore = coreAllocationRange(selection.tier, nextLevel, 'weapon');
for (const key of coreKeys) selection[key] = remapRangeValue(selection[key], { ...previousCore, min: 0, max: previousCore.poolMax }, { ...nextCore, min: 0, max: nextCore.poolMax });
for (const roll of selection.rolls) {
const stat = attribute(roll.statId);
if (!stat) continue;
roll.value = remapRangeValue(
roll.value,
attributeRange(stat, 'weapon', selection.level),
attributeRange(stat, 'weapon', nextLevel)
);
}
selection.level = nextLevel;
};
const activeParts = $derived(active.split(':'));
const talentCooldown = (id: string) => {
const value = talent(id)?.cooldown_seconds;
if (value === null || value === undefined || value === '') return '';
return `Talent Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
};
const summary = $derived(summarizeLoadout(draft, data));
const modeledTalents = $derived(summary.equippedTalents.filter(
(equipped: any) => Number(equipped.rule?.rule?.configured) === 1
&& (equipped.rule?.clauses?.length ?? 0) > 0
));
const calculationDraft = $derived.by(() => {
if (draft.calculationMode === 'custom') return draft;
const effectKeys = [
...selectedCombatEffects.map((effect: any) => String(effect.id)),
...modeledTalents.map((effect: any) => String(effect.key))
];
const conditionalEffects = {
...draft.conditionalEffects,
...Object.fromEntries(effectKeys.map((key) => [
key,
{ ...draft.conditionalEffects[key], mode: 'peak' as const }
]))
};
const skillActivations = { ...draft.skillActivations };
for (const ability of selectedFocus?.abilities ?? []) {
skillActivations[ability.id] = {
mode: 'on_cooldown',
targets: Math.max(1, Number(ability.attack?.target_cap ?? 1))
};
}
return { ...draft, conditionalEffects, skillActivations };
});
const combatResults = $derived(evaluateLoadoutCombat(calculationDraft, data, summary));
const skillCombatResult = $derived(evaluateLoadoutSkillCombat(calculationDraft, data, summary));
const skillAbilityNames = $derived(Object.fromEntries(
(selectedFocus?.abilities ?? []).map((ability: any) => [String(ability.id), String(ability.name)])
));
const rollSummary = (rolls: Roll[]) => rolls
.filter((roll) => roll.statId)
.map((roll) => `${attribute(roll.statId)?.name ?? 'Attribute'} ${unitValue(roll.value, roll.statId)}`)
.join(' · ');
const gearIcon = (slot: string) => `/${({
Holster: 'holster.png',
Gloves: 'gloves.png',
Backpack: 'backpack.png',
'Knee Pads': 'knee_pads.png',
'Body Armor': 'body_armor.png',
Mask: 'mask.png'
} as Record<string, string>)[slot] ?? 'backpack.png'}`;
const weaponIcon = (category: unknown) => {
const value = String(category ?? '').toLowerCase();
if (value.includes('shotgun')) return '/shotgun.png';
if (value.includes('smg') || value.includes('submachine')) return '/smg.png';
return '/rifle.png';
};
const doctrineCell = $derived<MatrixCell>({
id: 'specialization:focus',
label: 'SPECIALIZATION + FOCUS',
name: selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'SELECT DOCTRINE',
meta: selectedFocus?.primary_attribute ?? '',
summary: selectedFocus
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES',
iconUrl: selectedFocus?.specialization.iconUrl,
issue: !selectedFocus ? 'REQUIRED FOR CALCULATION' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined
});
const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index];
const configuredChips = draft.chips.slice(index * 3, index * 3 + 3).filter((chip) => chip.setId);
const chipNames = configuredChips.map((chip) => data.chipSets.find((item) => item.id === chip.setId)?.name).filter(Boolean);
return {
id: `ability:${index}`,
label: `ABILITY 0${index + 1}`,
name: ability?.name ?? 'NOT ASSIGNED',
meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined
};
}));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
const item = weaponItem(index);
const talents = [selection.talent1Id, selection.talent2Id].map((id) => talent(id)?.name).filter(Boolean);
const details = [...talents, rollSummary(selection.rolls)].filter(Boolean).join(' · ');
return {
id: `weapon:${index}`,
label: `${index ? 'SECONDARY' : 'PRIMARY'} WEAPON`,
name: item?.name ?? 'EMPTY SLOT',
meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
iconUrl: weaponIcon(item?.category),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
};
}));
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
const selection = draft.gear[slot];
const item = gearItem(slot);
const details = [
brand(selection.brandId)?.name,
talent(selection.talentId)?.name,
rollSummary(selection.rolls)
].filter(Boolean).join(' · ');
return {
id: `gear:${slot}`,
label: slot.toUpperCase(),
name: item?.name ?? 'EMPTY SLOT',
meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
iconUrl: gearIcon(slot),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
};
}));
const protocolCell = $derived<MatrixCell>({
id: 'protocol:0',
label: 'OS PROTOCOL',
name: protocolItem()?.name ?? 'EMPTY SLOT',
meta: protocolItem()?.quality ?? '',
summary: protocolItem()
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL',
iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'REQUIRED FOR CALCULATION'
});
const loadoutConfigured = $derived(Boolean(
selectedFocus
&& selectedFocus.abilities.length === 3
&& draft.weapons.every((item) => item.itemId)
&& gearSlots.every((slot) => draft.gear[slot].itemId)
&& draft.chips.every((item) => item.setId)
&& draft.protocol.itemId
));
$effect(() => {
const sources = [
...selectedCombatEffects.map((effect: any) => ({ key: String(effect.id) })),
...modeledTalents
];
const missing = sources.filter((source: any) => !draft.conditionalEffects[source.key]);
if (missing.length) draft.conditionalEffects = {
...draft.conditionalEffects,
...Object.fromEntries(missing.map((effect: any) => [effect.key, { mode: 'off' }]))
};
const missingAbilities = (selectedFocus?.abilities ?? []).filter((ability: any) => !draft.skillActivations[ability.id]);
if (missingAbilities.length) draft.skillActivations = {
...draft.skillActivations,
...Object.fromEntries(missingAbilities.map((ability: any) => [ability.id, { mode: 'off', targets: 1 }]))
};
});
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 {
const parsed = parseLoadoutDraft(JSON.parse(stored));
if (parsed) {
draft = parsed;
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
} else localStorage.removeItem('shd-build-loadout-draft-v4');
} catch {
localStorage.removeItem('shd-build-loadout-draft-v4');
}
});
$effect(() => {
if (!form?.buildSaved || !form?.buildToken || form.buildToken === handledBuildToken) return;
handledBuildToken = form.buildToken;
persistedBuildId = form.savedBuild.id;
persistedSlug = form.savedBuild.slug;
persistedVisibility = form.savedBuild.visibility;
message = form.savedBuild.visibility === 'private'
? 'Private build saved to your account.'
: form.savedBuild.visibility === 'unlisted'
? 'Unlisted build saved. Anyone with the link can view it.'
: 'Build published to the SHD archive.';
});
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.');
}
</script>
<svelte:head><title>Build a loadout — SHD.build</title></svelte:head>
<main class="app-page builder-page">
<div class="builder-command">
<div class="page-title">
<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>
{:else}
<a class="primary" href="/sign-in?returnTo=%2Fbuilder">SIGN IN TO SHARE</a>
{/if}
</div>
</div>
{#if data.viewer}
<div class="builder-persistence">
<SegmentedChoice legend="ACCOUNT VISIBILITY" name="build-visibility" options={[
{ value:'private', label:'PRIVATE' },
{ value:'unlisted', label:'UNLISTED' },
{ value:'public', label:'PUBLIC' }
]} value={persistedVisibility} onchange={(values) => persistedVisibility = values[0]} required/>
<p>{persistedVisibility === 'private' ? 'Only you can open this build.' : persistedVisibility === 'unlisted' ? 'Anyone with its direct link can open this build.' : 'Listed publicly and accessible by direct link.'}</p>
{#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}
{#if form?.buildError}<p class="error" role="alert">{form.buildError}</p>{/if}
{#if !data.gear.length && !data.weapons.length && !data.chipSets.length && !data.protocols.length}<div class="builder-empty-data"><strong>ISAC DATA LINK EMPTY</strong><span>Add active equipment definitions in ISAC to populate this workbench.</span></div>{/if}
<div class="loadout-workspace">
<LoadoutMatrix
{active}
doctrine={doctrineCell}
abilities={abilityCells}
weapons={weaponCells}
gear={gearCells}
protocol={protocolCell}
configured={loadoutConfigured}
onselect={(id) => active = id}
/>
<section class="loadout-editor">
{#if activeParts[0] === 'specialization'}
<div class="loadout-editor-head"><div><span>AGENT CLASS</span><h2>SPECIALIZATION FOCUS</h2></div><b>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={specializationItems} value={draft.specialization.focusId} onchange={selectFocus} label="SPECIALIZATION + FOCUS"/>
{#if selectedFocus}
<div class="builder-specialization-identity"><span class="focus-composite-icon"><ItemIcon src={selectedFocus.specialization.iconUrl} alt={selectedFocus.specialization.name}/><GameIcon name={selectedFocus.primary_attribute.toLocaleLowerCase() as GameIconName} decorative/></span><div><small>{selectedFocus.specialization.name}</small><h3>{selectedFocus.name}</h3><p>{selectedFocus.description || 'No focus description has been entered.'}</p></div><b class:configured={Number(selectedFocus.builder_ready) === 1}>{Number(selectedFocus.builder_ready) === 1 ? 'BUILDER READY' : 'ISAC DATA INCOMPLETE'}</b></div>
<div class="builder-ability-chip-panels">
{#each [0,1,2] as abilityIndex}
{@const ability = selectedFocus.abilities[abilityIndex]}
<section>
<header><ItemIcon src={ability?.iconUrl} alt={ability?.name ?? `Ability ${abilityIndex + 1}`}/><div><small>ABILITY {abilityIndex + 1}</small><strong>{ability?.name ?? 'NOT CONFIGURED'}</strong></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></header>
<p>{ability?.description ?? 'ISAC requires this class ability before compatible Skill Chips can be equipped.'}</p>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[chipIndex].quality : 'SELECT CHIP'}</span></button>{/each}</div>
</section>
{/each}
</div>
{#if draft.calculationMode === 'custom'}<div class="builder-skill-activation">
<div class="builder-section-title"><div><span>SKILL ACTIVATION PROFILE</span><small>ALL ENABLED ABILITIES CONTRIBUTE TO COMBINED SKILL DPS</small></div><b>{selectedFocus.abilities.filter((ability: any) => (draft.skillActivations[ability.id]?.mode ?? 'off') !== 'off').length} ENABLED</b></div>
{#each selectedFocus.abilities as ability}
{@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }}
<section>
<header><ItemIcon src={ability.iconUrl} alt=""/><div><strong>{ability.name}</strong><small>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK PROFILE NOT CONFIGURED'}</small></div></header>
<SegmentedChoice legend={`${ability.name} activation`} name={`skill-${ability.id}-activation`} options={[{value:'off',label:'OFF'},{value:'on_cooldown',label:'ON COOLDOWN'},{value:'custom_per_minute',label:'CUSTOM / MIN'}]} value={activation.mode} onchange={(values) => setSkillActivation(ability.id,{mode:values[0]})}/>
<div class="builder-skill-assumptions">
{#if activation.mode === 'custom_per_minute'}<RangeValueControl label="Uses per minute" min={0.1} max={60} step={0.1} value={activation.usesPerMinute ?? 1} onchange={(value) => setSkillActivation(ability.id,{usesPerMinute:value})}/>{/if}
<RangeValueControl label="Targets hit" min={1} max={Math.max(1,Number(ability.attack?.target_cap ?? 1))} step={1} value={activation.targets} onchange={(value) => setSkillActivation(ability.id,{targets:value})}/>
</div>
</section>
{/each}
</div>{/if}
<div class="builder-specialization-telemetry"><span>POINTS <b>{selectedSpecializationPoints} / {data.specializationProgression.availablePoints}</b></span><span>CREDITS <b>{selectedSpecializationCosts.credits.toLocaleString()}</b></span>{#each selectedSpecializationCosts.resources as resource}<span>{String(resource[0]).replace('resource-electronics-','').replaceAll('-',' ').toUpperCase()} <b>× {resource[1]}</b></span>{/each}</div>
<div class="builder-focus-tree">
<div class="builder-section-title"><div><span>FOCUS UPGRADE TREE</span><small>PREVIOUS ROWS REQUIRE EVERY AVAILABLE RANK 1 + 2 BEFORE ADVANCING</small></div><b>{selectedFocus.nodes.length} NODES</b></div>
{#each [...new Set(selectedFocus.nodes.map((node: any) => Number(node.row_number)))] as row}
<section><header><span>ROW {String(row).padStart(2, '0')}</span><b>{row === 1 ? 'ENTRY ROW' : 'LOCKED BY PREVIOUS ROWS'}</b></header><div>
{#each selectedFocus.nodes.filter((node: any) => Number(node.row_number) === row) as node}
<article><ItemIcon src={node.iconUrl} alt={node.name}/><div><strong>{node.name}</strong><small>{node.description_template}</small></div><SegmentedChoice legend={`${node.name} rank`} name={`specialization-node-${node.id}`} options={[{value:'0',label:'OFF'},...Array.from({length:Number(node.max_rank)},(_,index)=>({value:String(index+1),label:`R${index+1}`,className:canSelectNodeRank(node,index+1)?'':'unavailable',disabled:!canSelectNodeRank(node,index+1)}))]} value={String(draft.specialization.nodeRanks[node.id] ?? 0)} onchange={(values) => setNodeRank(node,Number(values[0]))}/></article>
{/each}
</div></section>
{/each}
</div>
<CombatAssumptions bind:mode={draft.calculationMode} bind:profile={draft.combatProfile}/>
{#if selectedCombatEffects.length}
<div class="builder-conditional-effects">
<div class="builder-section-title"><div><span>CONDITIONAL EFFECT PROFILES</span><small>CHOOSE HOW EACH UNLOCKED EFFECT CONTRIBUTES TO DPS</small></div><b>{selectedCombatEffects.length} EFFECTS</b></div>
{#each selectedCombatEffects as effect}
<ConditionalEffectCard
id={effect.id}
name={effect.definition?.name ?? effect.nodeName}
description={combatEffectDescription(effect)}
facts={conditionalFacts(effect.rule)}
scenarioInputs={mechanicScenarioInputs(effect.rule)}
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[effect.id]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[effect.id]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
bind:effectState={draft.conditionalEffects[effect.id]}
calculationMode={draft.calculationMode}
/>
{/each}
</div>
{/if}
{#if modeledTalents.length}
<div class="builder-conditional-effects">
<div class="builder-section-title"><div><span>EQUIPPED TALENT MODELING</span><small>MODEL EACH MECHANIC PROGRAM OR EXCLUDE IT FROM CALCULATIONS</small></div><b>{modeledTalents.length} TALENTS</b></div>
{#each modeledTalents as equipped}
<ConditionalEffectCard
id={equipped.key}
name={equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}
description={equipped.description}
facts={conditionalFacts(equipped.rule)}
scenarioInputs={mechanicScenarioInputs(equipped.rule)}
appliedState={draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : draft.conditionalEffects[equipped.key]?.mode === 'peak' ? 'PEAK MODE' : draft.conditionalEffects[equipped.key]?.mode === 'encounter' ? 'ENCOUNTER MODE' : 'NOT APPLIED'}
bind:effectState={draft.conditionalEffects[equipped.key]}
calculationMode={draft.calculationMode}
/>
{/each}
</div>
{/if}
{/if}
{:else if activeParts[0] === 'ability'}
{@const abilityIndex = Number(activeParts[1])}
{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>DOCTRINE DEPENDENCY · ABILITY 0{abilityIndex + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'ABILITY NOT ASSIGNED'}</h2></div><b>{draft.chips.slice(abilityIndex * 3, abilityIndex * 3 + 3).filter((chip) => chip.setId).length} / 3 CHIPS</b></div>
<button class="builder-return-link" type="button" onclick={() => active = 'specialization:focus'}>← CHANGE SPECIALIZATION + FOCUS</button>
{#if ability}
<div class="builder-specialization-identity ability-identity"><ItemIcon src={ability.iconUrl} alt={ability.name}/><div><small>{selectedFocus.specialization.name} · {selectedFocus.name}</small><h3>{ability.name}</h3><p>{ability.description || 'No ability description has been entered.'}</p></div><b>{ability.attack ? `${ability.attack.delivery.toUpperCase()} · ${ability.attack.damage_type}` : 'ATTACK DATA INCOMPLETE'}</b></div>
<div class="builder-section-title"><div><span>ATTACHED SKILL CHIPS</span><small>CHIPS ARE COMPATIBLE WITH THIS ABILITY AND MOVE WITH ITS FOCUS</small></div><b>03 POSITIONS</b></div>
<div class="builder-ability-chip-panels single">
<section>
<div>{#each [0,1,2] as chipSlot}{@const chipIndex = abilityIndex * 3 + chipSlot}{@const item = chipItem(chipIndex)}<button type="button" onclick={() => active = `chip:${chipIndex}`}><small>CHIP {chipSlot + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${draft.chips[chipIndex].quality} · ${attribute(draft.chips[chipIndex].primary.statId)?.name ?? 'NO PRIMARY'}` : 'SELECT CHIP'}</span></button>{/each}</div>
</section>
</div>
{#if draft.calculationMode === 'custom'}
{@const activation = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }}
<div class="builder-skill-activation">
<div class="builder-section-title"><div><span>ACTIVATION PROFILE</span><small>CONTROLS THIS ABILITY'S CONTRIBUTION TO COMBINED SKILL DPS</small></div></div>
<section>
<SegmentedChoice legend={`${ability.name} activation`} name={`ability-${ability.id}-activation`} options={[{value:'off',label:'OFF'},{value:'on_cooldown',label:'ON COOLDOWN'},{value:'custom_per_minute',label:'CUSTOM / MIN'}]} value={activation.mode} onchange={(values) => setSkillActivation(ability.id,{mode:values[0]})}/>
<div class="builder-skill-assumptions">
{#if activation.mode === 'custom_per_minute'}<RangeValueControl label="Uses per minute" min={0.1} max={60} step={0.1} value={activation.usesPerMinute ?? 1} onchange={(value) => setSkillActivation(ability.id,{usesPerMinute:value})}/>{/if}
<RangeValueControl label="Targets hit" min={1} max={Math.max(1,Number(ability.attack?.target_cap ?? 1))} step={1} value={activation.targets} onchange={(value) => setSkillActivation(ability.id,{targets:value})}/>
</div>
</section>
</div>
{/if}
{:else}
<p class="error">Select a Specialization and Focus with configured abilities before attaching Skill Chips.</p>
{/if}
{:else if activeParts[0] === 'gear'}
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
{#if item}
{@const healthRange = gearBaseRange(item, 'health', selection.level)}
{@const armorRange = item.base_armor_min !== null ? gearBaseRange(item, 'armor', selection.level) : null}
<div class="builder-equipment-controls">
<RangeValueControl label="GEAR LEVEL" min={Number(item.tier) === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeGearLevel(slot,value)}/>
<BuilderRadioGrid label="BRAND SET" name={`gear-${slot}-brand`} items={item.brandIds.map((id: string) => brand(id)).filter(Boolean)} bind:value={selection.brandId} columns={Math.min(item.brandIds.length,4)} iconOnly required/>
{#if item.talentIds.length}<BuilderRadioGrid label="EQUIPPED TALENT" name={`gear-${slot}-talent`} items={item.talentIds.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talentId} columns={Math.min(item.talentIds.length,4)} searchable={item.talentIds.length > 6} required/>{/if}
</div>
<p class:provisional={healthRange.provisional} class="builder-scaling-note">{healthRange.provisional ? 'Estimated range: levels 1–9 remain provisional while additional game samples are collected.' : 'Base Health and Armor are calculated from the stored gear base ranges at the selected item level. Display values are truncated to match the game.'}</p>
<div class="builder-range-grid"><RangeValueControl label="BASE HEALTH" min={healthRange.min} max={healthRange.max} provisional={healthRange.provisional} bind:value={selection.health}/>{#if armorRange}<RangeValueControl label="BASE ARMOR" min={armorRange.min} max={armorRange.max} provisional={armorRange.provisional} bind:value={selection.armor}/>{/if}</div>
{@const gearCore = coreAllocationRange(Number(item.tier), selection.level, 'gear')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{item.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < gearCore.poolMin || coreTotal(selection) > gearCore.poolMax}>{coreTotal(selection)} / {gearCore.poolMin}–{gearCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={gearCore.poolMax} bind:value={selection[key]}/>{/each}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO GEAR RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, index}{@const rollRange = attributeRange(attribute(roll.statId), 'gear', selection.level)}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== index).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${index + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>ENABLE COLLECTED UPGRADES · BASE BONUSES APPLY TO THIS ITEM ONLY</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
{/if}
{:else if activeParts[0] === 'weapon'}
{@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}
<div class="loadout-editor-head"><div><span>WEAPON SLOT</span><h2>{index ? 'SECONDARY' : 'PRIMARY'} WEAPON</h2></div><b>{item ? item.damage_type : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.weapons} bind:value={selection.itemId} onchange={(id) => configureWeapon(index,id)} disabledIds={other && Number(other.is_exotic) ? data.weapons.filter((entry) => Number(entry.is_exotic)).map((entry) => entry.id) : []} label="WEAPON DEFINITION"/>
{#if item}
{#if Number(item.is_exotic) && other && Number(other.is_exotic)}<p class="error">Only one exotic weapon may be equipped. Choose a standard weapon in either slot.</p>{/if}
<div class="builder-weapon-controls">
<RangeValueControl label="WEAPON LEVEL" min={selection.tier === 2 ? 40 : 1} max={50} value={selection.level} onchange={(value) => changeWeaponLevel(index,value)}/>
<SegmentedChoice legend="TIER" name={`weapon-${index}-tier`} options={item.ranges.map((range: any) => ({ value: String(range.tier), label: `T${range.tier}` }))} value={String(selection.tier)} onchange={(values) => changeWeaponTier(index,Number(values[0]))} required/>
<div class="builder-fixed"><span>{item.rpm} RPM</span><span>{item.magazine} SHOTS</span><span>{item.reload_speed}s RELOAD</span></div>
</div>
{@const range = item.ranges.find((entry: any) => Number(entry.tier) === selection.tier)}
{#if range}
<p class:provisional={selection.level < 40} class="builder-scaling-note">{selection.level < 40 ? 'Estimated range: the weapon curve below level 40 is provisional and may change as observations are added.' : 'Damage is calculated from the stored weapon base range at the selected item level. Core attributes use the shared level-50 tier allocation pool.'}</p>
{@const damageRange = weaponStatRange(range,'damage',selection.level)}
<div class="builder-range-grid weapon-damage"><RangeValueControl label="DAMAGE" min={damageRange.min} max={damageRange.max} provisional={damageRange.provisional} bind:value={selection.damage}/></div>
{@const weaponCore = coreAllocationRange(selection.tier, selection.level, 'weapon')}
<div class="builder-section-title"><div><span>CORE ATTRIBUTE ALLOCATION</span><small>SHARED T{selection.tier} POOL · VALUES MAY BE REDISTRIBUTED BETWEEN CORES</small></div><b class:restricted={coreTotal(selection) < weaponCore.poolMin || coreTotal(selection) > weaponCore.poolMax}>{coreTotal(selection)} / {weaponCore.poolMin}–{weaponCore.poolMax}</b></div>
<div class="builder-core-grid">{#each coreKeys as key}<RangeValueControl label={key.toUpperCase()} min={0} max={weaponCore.poolMax} bind:value={selection[key]}/>{/each}</div>
{/if}
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>TWO REQUIRED SLOTS · VALUES ARE LIMITED TO WEAPON RANGES</small></div></div>
<div class="builder-rolls required">{#each selection.rolls as roll, rollIndex}{@const rollRange = attributeRange(attribute(roll.statId), 'weapon', selection.level)}<div><BuilderPicker items={weaponAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'weapon',selection.level)} disabledIds={selection.rolls.filter((_, itemIndex) => itemIndex !== rollIndex).map((entry) => entry.statId).filter(Boolean)} label={`ATTRIBUTE ${rollIndex + 1}`}/><RangeValueControl label="VALUE" min={rollRange.min} max={rollRange.max} provisional={rollRange.provisional} bind:value={roll.value}/></div>{/each}</div>
<div class="builder-talent-grids">
{#if item.talent1Ids.length}<BuilderRadioGrid label="TALENT 1" name={`weapon-${index}-talent-1`} items={item.talent1Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent1Id} columns={Math.min(item.talent1Ids.length,4)} searchable={item.talent1Ids.length > 6} required/>{/if}
{#if item.talent2Ids.length}<BuilderRadioGrid label="TALENT 2" name={`weapon-${index}-talent-2`} items={item.talent2Ids.map((id: string) => talent(id)).filter(Boolean)} bind:value={selection.talent2Id} columns={Math.min(item.talent2Ids.length,4)} searchable={item.talent2Ids.length > 6} required/>{/if}
</div>
{#if item.baseBonuses.length}<div class="builder-inherited">{#each item.baseBonuses as bonus}<span><small>{attribute(bonus.stat_attribute_id)?.name}</small><b>{unitValue(bonus.value,bonus.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
{:else if activeParts[0] === 'chip'}
{@const index = Number(activeParts[1])}{@const chip = draft.chips[index]}{@const item = chipItem(index)}{@const abilityIndex = Math.floor(index / 3)}{@const ability = selectedFocus?.abilities[abilityIndex]}
<div class="loadout-editor-head"><div><span>ABILITY {abilityIndex + 1} · CHIP {(index % 3) + 1}</span><h2>{ability?.name?.toUpperCase() ?? 'SKILL CHIP'}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<button class="builder-return-link" type="button" onclick={() => active = `ability:${abilityIndex}`}>← RETURN TO {ability?.name?.toUpperCase() ?? 'ABILITY'}</button>
{#if ability}<BuilderPicker items={data.chipSets.filter((entry: any) => entry.specialization_ability_id === ability.id)} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="ABILITY-COMPATIBLE SKILL CHIP SET"/>{:else}<p class="error">Select a focus with three configured class abilities before equipping Skill Chips.</p>{/if}
{#if item}<div class="builder-field-grid"><label>QUALITY<select bind:value={chip.quality}><option>Superior</option><option>High-end</option></select></label></div><div class="builder-chip-rolls"><div><BuilderPicker items={chipAttributes.filter((entry) => item.primaryStatIds.includes(entry.id))} bind:value={chip.primary.statId} onchange={(id) => setRollStat(chip.primary,id,'chip')} label="PRIMARY ATTRIBUTE"/><label>VALUE<input type="number" min={attribute(chip.primary.statId)?.skill_chip_min ?? 0} max={attribute(chip.primary.statId)?.skill_chip_max ?? 0} bind:value={chip.primary.value} onblur={() => chip.primary.value = clamp(chip.primary.value,attribute(chip.primary.statId)?.skill_chip_min,attribute(chip.primary.statId)?.skill_chip_max)}/></label></div>{#each chip.secondary as roll,index}<div><BuilderPicker items={chipAttributes.filter((entry) => item.secondaryStatIds.includes(entry.id))} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'chip')} disabledIds={chip.secondary.filter((_,i) => i !== index).map((entry) => entry.statId)} label={`SECONDARY ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" min={attribute(roll.statId)?.skill_chip_min ?? 0} max={attribute(roll.statId)?.skill_chip_max ?? 0} bind:value={roll.value} onblur={() => roll.value = clamp(roll.value,attribute(roll.statId)?.skill_chip_min,attribute(roll.statId)?.skill_chip_max)}/></label></div>{/each}</div>{/if}
{:else}
{@const protocol = protocolItem()}
<div class="loadout-editor-head"><div><span>AGENT SYSTEM</span><h2>OS PROTOCOL</h2></div><b>{protocol?.quality?.toUpperCase() ?? 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.protocols} bind:value={draft.protocol.itemId} label="OS PROTOCOL"/>
{#if protocol}<div class="builder-range-grid"><label>PROTOCOL LEVEL <span>01–50</span><input type="number" min="1" max="50" bind:value={draft.protocol.level} onblur={() => draft.protocol.level = clamp(draft.protocol.level,1,50)}/></label><label>TALENT UPGRADES <span>0–5</span><input type="number" min="0" max="5" bind:value={draft.protocol.talentUpgrades} onblur={() => draft.protocol.talentUpgrades = clamp(draft.protocol.talentUpgrades,0,5)}/></label></div><div class="builder-inherited">{#if protocol.primaryEffect}<span><small>{attribute(protocol.primaryEffect.stat_attribute_id)?.name} · LEVEL {draft.protocol.level}</small><b>{unitValue(Number(protocol.primaryEffect.base_value)*(1+(draft.protocol.level-1)/49),protocol.primaryEffect.stat_attribute_id)}</b></span>{/if}{#each protocol.secondaryEffects as effect}<span><small>{attribute(effect.stat_attribute_id)?.name}</small><b>{unitValue(effect.value,effect.stat_attribute_id)}</b></span>{/each}</div>{/if}
{/if}
</section>
<aside class="loadout-summary">
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result} resolverHint="Use the warning markers in the loadout matrix."/>{/each}</div>
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames} resolverHint="Open the marked doctrine or ability cell."/></div>
<div class="summary-scroll">
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
<div class="summary-section"><header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>{#each summary.equippedTalents as equipped}<label class="summary-talent">{#if draft.calculationMode === 'custom'}<input type="checkbox" checked={(draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off'} onchange={() => toggleTalent(equipped.key)}/>{/if}<span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : (draft.conditionalEffects[equipped.key]?.mode ?? 'off') !== 'off' ? 'INCLUDED' : 'CONDITIONAL'}</small><em>{equipped.description}</em>{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}</span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
</div>
<p class="notice">{draft.calculationMode === 'maximum' ? 'Maximum Potential applies every configured effect at best-case uptime. ' : 'Custom Scenario uses each mechanic program and the encounter settings saved with this loadout. '}Anonymous drafts remain on this device.</p>
</aside>
</div>
</main>