783 lines
57 KiB
Svelte
783 lines
57 KiB
Svelte
<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>
|