Build catalog-backed loadout workbench

This commit is contained in:
JB
2026-07-25 16:56:27 -04:00
parent 095b697b08
commit e5f0414a8e
6 changed files with 462 additions and 22 deletions
+23
View File
@@ -814,3 +814,26 @@ Attribute relationship, one numeric effect value, and one non-negative
whole-number unlock cost. At narrow widths profile cards and augment editor whole-number unlock cost. At narrow widths profile cards and augment editor
cards reduce from multi-column grids to a single column while retaining cards reduce from multi-column grids to a single column while retaining
44-pixel interactive targets. 44-pixel interactive targets.
### Loadout workbench
The builder is a three-rail tactical workbench: compact equipment positions on
the left, the currently selected item editor in the center, and a sticky live
calculation summary on the right. At medium widths the summary becomes a
full-width lower rail; on mobile all three rails stack, with equipment first,
editing second, and results third. Equipment positions remain explicit even
when empty so loadout completeness is always visible.
Use the shared `BuilderPicker` for builder catalogs and Attribute pools. It
combines an always-visible current selection with a searchable overlay, icon or
tactical fallback mark, classification metadata, selected state, and conflict
state. Conflicting catalog entries remain visible but disabled so restrictions
such as the single-exotic-weapon rule are explained rather than hidden.
Numeric instance values display their authoritative minimum and maximum beside
the field and clamp to that range on completion. Always-on item rolls, fixed
bonuses, inherited profiles, enabled augments, and activated set bonuses feed
the live totals immediately. Trigger-dependent talents remain explicitly
`CONDITIONAL` until the user enables `TRIGGER ACTIVE`; only then may their
effects enter combined totals. This textual distinction must remain clear
without relying on color.
+3
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -0,0 +1,38 @@
export type Roll = { statId: string; value: number };
export type GearSelection = { itemId: string; brandId: string; talentId: string; health: number; armor: number; rolls: Roll[]; augmentSlots: number[] };
export type WeaponSelection = { itemId: string; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string };
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
export type LoadoutDraft = {
version: 1;
buildName: string;
gear: Record<string, GearSelection>;
weapons: [WeaponSelection, WeaponSelection];
chips: [ChipSelection, ChipSelection, ChipSelection];
protocol: ProtocolSelection;
activeTalents: string[];
};
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
export const blankGear = (): GearSelection => ({ itemId: '', brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll()], augmentSlots: [] });
export const blankWeapon = (): WeaponSelection => ({ itemId: '', tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '' });
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
export const createDraft = (): LoadoutDraft => ({
version: 1,
buildName: 'Untitled loadout',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
weapons: [blankWeapon(), blankWeapon()],
chips: [blankChip(), blankChip(), blankChip()],
protocol: { itemId: '', level: 1, talentUpgrades: 0 },
activeTalents: []
});
export const clamp = (value: unknown, min: unknown, max: unknown) => {
const low = Number(min), high = Number(max), numeric = Number(value);
if (!Number.isFinite(numeric)) return Number.isFinite(low) ? low : 0;
return Math.min(Number.isFinite(high) ? high : numeric, Math.max(Number.isFinite(low) ? low : numeric, numeric));
};
export const scaled = (min: unknown, max: unknown, progress: number, steps: number) =>
Number(min) + (Number(max) - Number(min)) * Math.min(steps, Math.max(0, progress)) / steps;
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
let {
items,
value = $bindable(''),
label,
placeholder = 'Search available items…',
disabledIds = [],
onchange
}: {
items: Array<{ id: string; name: string; iconUrl?: string; category?: string; pending?: boolean }>;
value?: string;
label: string;
placeholder?: string;
disabledIds?: string[];
onchange?: (id: string) => void;
} = $props();
let open = $state(false);
let query = $state('');
const selected = $derived(items.find((item) => item.id === value));
const results = $derived(items.filter((item) => item.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())).slice(0, 40));
const choose = (id: string) => { value = id; onchange?.(id); open = false; query = ''; };
</script>
<div class="builder-picker">
<span>{label}</span>
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
{#if selected?.iconUrl}<img src={selected.iconUrl} alt=""/>{:else}<i>◇</i>{/if}
<b>{selected?.name ?? 'SELECT ITEM'}</b><small>{selected?.category ?? (selected ? 'EQUIPPED' : 'UNASSIGNED')}</small><em>{open ? '×' : '+'}</em>
</button>
{#if open}
<div class="builder-picker-panel">
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
<div>
{#if selected}<button type="button" class="builder-picker-clear" onclick={() => choose('')}><i>×</i><span><b>CLEAR SLOT</b><small>REMOVE CURRENT SELECTION</small></span><em>CLEAR</em></button>{/if}
{#each results as item}
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
{#if item.iconUrl}<img src={item.iconUrl} alt=""/>{:else}<i>◇</i>{/if}
<span><b>{item.name}</b><small>{item.category ?? 'AVAILABLE'}</small></span><em>{item.id === value ? 'EQUIPPED' : disabledIds.includes(item.id) ? 'CONFLICT' : 'SELECT'}</em>
</button>
{:else}<p>NO MATCHING ITEMS</p>{/each}
</div>
</div>
{/if}
</div>
+101
View File
@@ -0,0 +1,101 @@
import { getDatabase } from '$lib/server/database';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const database = await getDatabase();
const [
attributes, gear, gearBrands, gearTalents, gearAugments, brands, brandEffects,
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
talents, talentTiers, talentEffects, talentTemplates,
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects,
icons
] = await Promise.all([
(await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM gear_brand_sets')).all(),
(await database.prepare('SELECT * FROM gear_talents')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
(await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(),
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_talent_options ORDER BY talent_slot')).all(),
(await database.prepare('SELECT * FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
(await database.prepare('SELECT * FROM talent_description_templates')).all(),
(await database.prepare('SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
(await database.prepare('SELECT * FROM skill_chip_secondary_options')).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(),
(await database.prepare('SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles')).all(),
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
(await database.prepare("SELECT * FROM game_data_icons")).all()
]);
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip', id: unknown) => {
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
};
const configuredTalents = talents.map((talent) => ({
...talent,
iconUrl: iconUrl('talent', talent.id),
template: talentTemplates.find((item) => item.talent_id === talent.id)?.template ?? '',
tiers: talentTiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
...tier,
effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id)
}))
}));
return {
attributes,
gear: gear.map((item) => ({
...item,
brandIds: gearBrands.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
talentIds: gearTalents.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
})),
gearAugments,
brands: brands.map((brand) => ({
...brand,
iconUrl: iconUrl('brand_set', brand.id),
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
})),
weapons: weapons.map((weapon) => ({
...weapon,
ranges: weaponRanges.filter((range) => range.weapon_id === weapon.id),
talent1Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 1).map((link) => String(link.talent_id)),
talent2Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 2).map((link) => String(link.talent_id)),
baseBonuses: weaponBonuses.filter((bonus) => bonus.weapon_id === weapon.id)
})),
talents: configuredTalents,
chipSets: chipSets.map((set) => {
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
return {
...set,
iconUrl: iconUrl('skill_chip', set.id),
twoPiece: chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
primaryStatIds: chipPrimary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
secondaryStatIds: chipSecondary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
talent: talent ? { ...talent, effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id) } : null
};
}),
protocols: protocols.map((protocol) => {
const profile = protocolProfiles.find((item) => item.quality === protocol.quality && item.primary_attribute_type === protocol.primary_attribute_type);
const talent = protocolTalents.find((item) => item.protocol_id === protocol.id);
return {
...protocol,
iconUrl: iconUrl('os_protocol', protocol.id),
primaryEffect: protocolPrimary.find((item) => item.protocol_id === protocol.id) ?? null,
secondaryEffects: profile ? protocolSecondary.filter((effect) => effect.profile_id === profile.id) : [],
talent: talent ? { ...talent, effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id) } : null
};
})
};
};
+253 -22
View File
@@ -1,30 +1,207 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import GameIcon from '$lib/components/GameIcon.svelte'; import GameIcon from '$lib/components/GameIcon.svelte';
type Slot = { name: string; item: string; brand: string; core: string }; import type { GameIconName } from '$lib/game-icons';
let buildName = $state('Untitled loadout'); import BuilderPicker from '$lib/components/BuilderPicker.svelte';
import {
blankRoll, clamp, createDraft, gearSlots, scaled,
type LoadoutDraft, type Roll
} from '$lib/builder/loadout';
let { data } = $props();
let draft = $state<LoadoutDraft>(createDraft());
let active = $state('gear:Holster');
let saved = $state(false); let saved = $state(false);
let message = $state(''); let message = $state('');
let slots = $state<Slot[]>([ const weaponRangeKeys = ['damage','firepower','toughness','engineering'] as const;
{ name: 'Primary weapon', item: 'Police M4', brand: 'Assault rifle', core: '5,184 DMG' }, const gearAttributes = $derived(data.attributes.filter((item) => item.gear_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
{ name: 'Secondary weapon', item: 'SVD', brand: 'Marksman rifle', core: '8,420 DMG' }, const weaponAttributes = $derived(data.attributes.filter((item) => item.weapon_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
{ name: 'Mask', item: 'Striker Mask', brand: "Striker's Battlegear", core: '+42 Firearms' }, const chipAttributes = $derived(data.attributes.filter((item) => item.skill_chip_min !== null).map((item) => ({ ...item, category: `${item.type} · ${item.unit || 'FLAT'}` })));
{ name: 'Vest', item: 'Reinforced Vest', brand: 'Fenris Group AB', core: '+38 Armor' }, const attribute = (id: string) => data.attributes.find((item) => item.id === id);
{ name: 'Backpack', item: 'Field Pack', brand: "Striker's Battlegear", core: '+39 Firearms' }, const gearItem = (slot: string) => data.gear.find((item) => item.id === draft.gear[slot].itemId);
{ name: 'Gloves', item: 'Tactical Gloves', brand: 'Sokolov Concern', core: '+6.5% CHC' } 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 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 setRollStat = (roll: Roll, id: string, source: 'gear' | 'weapon' | 'chip') => {
roll.statId = id;
const stat = attribute(id);
const min = source === 'gear' ? stat?.gear_min : source === 'weapon' ? stat?.weapon_min : stat?.skill_chip_min;
roll.value = Number(min ?? 0);
};
const toggleTalent = (key: string) => {
draft.activeTalents = draft.activeTalents.includes(key)
? draft.activeTalents.filter((item) => item !== key)
: [...draft.activeTalents, key];
};
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.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);
selection.rolls = [blankRoll()];
selection.augmentSlots = [];
};
const configureWeapon = (index: number, id: string) => {
const selection = draft.weapons[index], item = data.weapons.find((entry) => entry.id === id);
selection.itemId = id;
selection.tier = Number(item?.native_tier ?? 0);
const range = item?.ranges.find((entry: any) => Number(entry.tier) === selection.tier);
for (const key of ['damage', 'firepower', 'toughness', 'engineering'] as const) selection[key] = Number(range?.[`${key}_min`] ?? 0);
selection.talent1Id = item?.talent1Ids[0] ?? '';
selection.talent2Id = item?.talent2Ids[0] ?? '';
};
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;
const range = item?.ranges.find((entry: any) => Number(entry.tier) === tier);
for (const key of ['damage', 'firepower', 'toughness', 'engineering'] as const) selection[key] = Number(range?.[`${key}_min`] ?? 0);
};
const activeParts = $derived(active.split(':'));
const summary = $derived.by(() => {
const values = new Map<string, number>();
const sources = new Map<string, Set<string>>();
const add = (statId: string, value: unknown, source: string) => {
if (!statId || !Number.isFinite(Number(value))) return;
const stat = attribute(statId), current = values.get(statId) ?? 0, incoming = Number(value);
const combined = stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
: current + incoming;
values.set(statId, stat?.cap_value === null || stat?.cap_value === undefined ? combined : Math.min(combined, Number(stat.cap_value)));
if (!sources.has(statId)) sources.set(statId, new Set());
sources.get(statId)!.add(source);
};
const byName = (name: string) => data.attributes.find((item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.id ?? '';
const brandCounts = new Map<string, number>();
const activeBonuses: any[] = [], equippedTalents: any[] = [];
for (const slot of gearSlots) {
const selection = draft.gear[slot], item = gearItem(slot);
if (!item) continue;
add(byName('Health'), selection.health, `${slot} base`);
add(byName('Armor'), selection.armor, `${slot} base`);
for (const roll of selection.rolls) add(roll.statId, roll.value, `${slot} roll`);
if (selection.brandId) brandCounts.set(selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1);
for (const augment of data.gearAugments.filter((entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot)))) {
add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`);
}
if (selection.talentId) equippedTalents.push({ key: `gear:${slot}:${selection.talentId}`, id: selection.talentId, tier: Number(item.tier), source: slot, description: talentDescription(selection.talentId, Number(item.tier)) });
}
for (const [brandId, count] of brandCounts) {
const set = brand(brandId);
for (const effect of set?.effects.filter((entry: any) => Number(entry.pieces_required) <= count) ?? []) {
add(effect.stat_attribute_id, effect.value, `${set.name} ${effect.pieces_required}-piece`);
activeBonuses.push({ name: set.name, pieces: effect.pieces_required, count, statId: effect.stat_attribute_id, value: effect.value });
}
}
for (let index = 0; index < 2; index++) {
const selection = draft.weapons[index], item = weaponItem(index);
if (!item) continue;
for (const name of ['Damage', 'Firepower', 'Toughness', 'Engineering'] as const) add(byName(name), selection[name.toLowerCase() as 'damage'], `${index ? 'Secondary' : 'Primary'} weapon`);
for (const bonus of item.baseBonuses) add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
for (const talentId of [selection.talent1Id, selection.talent2Id, item.exotic_talent_id].filter(Boolean)) equippedTalents.push({
key: `weapon:${index}:${talentId}`, id: talentId, tier: selection.tier, source: index ? 'Secondary weapon' : 'Primary weapon', description: talentDescription(talentId, selection.tier)
});
}
const chipGroups = new Map<string, { count: number; highEnd: number; item: any }>();
for (let index = 0; index < 3; index++) {
const chip = draft.chips[index], item = chipItem(index);
if (!item) continue;
add(chip.primary.statId, chip.primary.value, `Skill Chip ${index + 1} primary`);
for (const roll of chip.secondary) add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
group.count += 1;
if (chip.quality === 'High-end') group.highEnd += 1;
chipGroups.set(item.id, group);
}
for (const group of chipGroups.values()) {
if (group.count >= 2 && group.item.twoPiece) {
const value = scaled(group.item.twoPiece.min_value, group.item.twoPiece.max_value, group.highEnd, 3);
add(group.item.twoPiece.stat_attribute_id, value, `${group.item.name} 2-piece`);
activeBonuses.push({ name: group.item.name, pieces: 2, count: group.count, statId: group.item.twoPiece.stat_attribute_id, value });
}
if (group.count === 3 && group.item.talent) equippedTalents.push({
key: `chips:${group.item.id}`, id: '', custom: group.item.talent, tier: group.highEnd, source: '3 Skill Chips',
description: group.item.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
const effect = group.item.talent.effects[Number(raw) - 1];
return effect ? unitValue(scaled(effect.min_value, effect.max_value, group.highEnd, 3), effect.stat_attribute_id) : placeholder;
})
});
}
const protocol = protocolItem();
if (protocol) {
if (protocol.primaryEffect) add(protocol.primaryEffect.stat_attribute_id, Number(protocol.primaryEffect.base_value) * (1 + (draft.protocol.level - 1) / 49), `${protocol.name} primary`);
for (const effect of protocol.secondaryEffects) add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
if (protocol.talent) equippedTalents.push({
key: `protocol:${protocol.id}`, id: '', custom: protocol.talent, tier: draft.protocol.talentUpgrades, source: 'OS Protocol',
description: protocol.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, raw: string) => {
const effect = protocol.talent.effects[Number(raw) - 1];
return effect ? unitValue(Number(effect.base_value) * (1 + draft.protocol.talentUpgrades / 5), effect.stat_attribute_id) : placeholder;
})
});
}
for (const equipped of equippedTalents) {
if (!draft.activeTalents.includes(equipped.key)) continue;
const effects = equipped.custom?.effects ?? talentTier(equipped.id, equipped.tier)?.effects ?? [];
for (const effect of effects) {
const value = equipped.key.startsWith('chips:') ? scaled(effect.min_value, effect.max_value, equipped.tier, 3)
: equipped.key.startsWith('protocol:') ? Number(effect.base_value) * (1 + equipped.tier / 5) : effect.value;
add(effect.stat_attribute_id, value, `${equipped.source} talent`);
}
}
return {
stats: [...values].map(([id, value]) => ({ id, value, sourceCount: sources.get(id)?.size ?? 0 })).sort((a, b) => (attribute(a.id)?.category ?? '').localeCompare(attribute(b.id)?.category ?? '') || (attribute(a.id)?.name ?? '').localeCompare(attribute(b.id)?.name ?? '')),
activeBonuses,
equippedTalents
};
});
onMount(() => { onMount(() => {
const draft = localStorage.getItem('shd-build-loadout-draft'); const stored = localStorage.getItem('shd-build-loadout-draft-v1');
if (!draft) return; if (!stored) return;
try { try {
const parsed = JSON.parse(draft); const parsed = JSON.parse(stored);
buildName = parsed.buildName ?? buildName; if (parsed?.version === 1) {
slots = parsed.slots ?? slots; draft = parsed;
saved = true; if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
} catch { localStorage.removeItem('shd-build-loadout-draft'); } saved = true;
}
} catch { localStorage.removeItem('shd-build-loadout-draft-v1'); }
}); });
function saveDraft() { function saveDraft() {
localStorage.setItem('shd-build-loadout-draft', JSON.stringify({ buildName, slots, updatedAt: new Date().toISOString() })); localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft));
saved = true; message = 'Draft saved on this device.'; saved = true; message = 'Draft saved on this device.';
setTimeout(() => (message = ''), 2200); setTimeout(() => (message = ''), 2200);
} }
@@ -32,10 +209,64 @@
<svelte:head><title>Build a loadout — SHD.build</title></svelte:head> <svelte:head><title>Build a loadout — SHD.build</title></svelte:head>
<main class="app-page builder-page"> <main class="app-page builder-page">
<div class="builder-command"><div class="page-title"><div><p class="eyebrow"><span></span> LOADOUT WORKBENCH</p><input bind:value={buildName} aria-label="Build name" /></div><div class="actions"><span class:visible={saved}>SAVED LOCALLY</span><button class="primary" onclick={saveDraft}>SAVE DRAFT</button></div></div><div class="builder-status"><span><i></i> DRAFT ACTIVE</span><span>6 EQUIPMENT SLOTS</span><span>LOCAL STORAGE</span></div></div> <div class="builder-command"><div class="page-title"><div><p class="eyebrow"><span></span> LOADOUT WORKBENCH</p><input bind:value={draft.buildName} aria-label="Build name"/></div><div class="actions"><span class:visible={saved}>SAVED LOCALLY</span><button class="primary" onclick={saveDraft}>SAVE DRAFT</button></div></div><div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>12 EQUIPMENT POSITIONS</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 message}<p class="success" role="status">{message}</p>{/if}
<div class="builder-layout"> {#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}
<section class="slot-grid">{#each slots as slot}<button class="slot-card" onclick={() => (message = `${slot.name} editing will be added in the next builder increment.`)}><span class="slot-label">{slot.name}</span><span class="slot-icon">◇</span><strong>{slot.item}</strong><small>{slot.brand}</small><b>{slot.core}</b></button>{/each}</section> <div class="loadout-workspace">
<aside class="summary"><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2><div class="stat"><span><GameIcon name="firepower" decorative/>Firepower</span><strong>4,218</strong><i style="width:76%"></i></div><div class="stat"><span><GameIcon name="toughness" decorative/>Toughness</span><strong>2,840</strong><i style="width:53%"></i></div><div class="stat"><span><GameIcon name="engineering" decorative/>Engineering</span><strong>1,265</strong><i style="width:30%"></i></div><hr/><div class="mini"><span>Critical chance</span><b>52.5%</b></div><div class="mini"><span>Critical damage</span><b>118%</b></div><p class="notice">Anonymous drafts stay on this device. Sign in to publish and sync builds.</p></aside> <section class="loadout-slots">
<header><span>EQUIPMENT</span><b>{gearSlots.filter((slot) => draft.gear[slot].itemId).length + draft.weapons.filter((item) => item.itemId).length + draft.chips.filter((item) => item.setId).length + (draft.protocol.itemId ? 1 : 0)} / 12 EQUIPPED</b></header>
<div class="loadout-slot-group weapons">{#each ['PRIMARY', 'SECONDARY'] as label, index}{@const item = weaponItem(index)}<button class:active={active === `weapon:${index}`} onclick={() => active = `weapon:${index}`}><small>{label} WEAPON</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? `${item.category}${Number(item.is_exotic) ? ' · EXOTIC' : ''}` : 'SELECT WEAPON'}</span></button>{/each}</div>
<div class="loadout-slot-group gear">{#each gearSlots as slot}{@const item = gearItem(slot)}<button class:active={active === `gear:${slot}`} onclick={() => active = `gear:${slot}`}><small>{slot}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? brand(draft.gear[slot].brandId)?.name ?? 'CHOOSE BRAND' : 'SELECT GEAR'}</span></button>{/each}</div>
<div class="loadout-slot-group systems">{#each [0,1,2] as index}{@const item = chipItem(index)}<button class:active={active === `chip:${index}`} onclick={() => active = `chip:${index}`}><small>SKILL CHIP 0{index + 1}</small><strong>{item?.name ?? 'EMPTY SLOT'}</strong><span>{item ? draft.chips[index].quality : 'SELECT CHIP'}</span></button>{/each}<button class:active={active === 'protocol:0'} onclick={() => active = 'protocol:0'}><small>OS PROTOCOL</small><strong>{protocolItem()?.name ?? 'EMPTY SLOT'}</strong><span>{protocolItem() ? `LEVEL ${draft.protocol.level}` : 'SELECT PROTOCOL'}</span></button></div>
</section>
<section class="loadout-editor">
{#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} · LEVEL ${item.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}
<div class="builder-field-grid"><label>BRAND SET<select value={selection.brandId} onchange={(event) => selection.brandId = event.currentTarget.value}>{#each item.brandIds as id}<option value={id}>{brand(id)?.name}</option>{/each}</select></label>{#if item.talentIds.length}<label>EQUIPPED TALENT<select bind:value={selection.talentId}>{#each item.talentIds as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/if}</div>
<div class="builder-range-grid"><label>BASE HEALTH <span>{item.base_health_min}–{item.base_health_max}</span><input type="number" min={item.base_health_min} max={item.base_health_max} bind:value={selection.health} onblur={() => selection.health = clamp(selection.health,item.base_health_min,item.base_health_max)}/></label>{#if item.base_armor_min !== null}<label>BASE ARMOR <span>{item.base_armor_min}–{item.base_armor_max}</span><input type="number" min={item.base_armor_min} max={item.base_armor_max} bind:value={selection.armor} onblur={() => selection.armor = clamp(selection.armor,item.base_armor_min,item.base_armor_max)}/></label>{/if}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>VALUES ARE LIMITED TO GEAR RANGES</small></div><button onclick={() => selection.rolls = [...selection.rolls, blankRoll()]} disabled={selection.rolls.length >= 6}>ADD ATTRIBUTE +</button></div>
<div class="builder-rolls">{#each selection.rolls as roll, index}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear')} label={`ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" value={roll.value} min={attribute(roll.statId)?.gear_min ?? 0} max={attribute(roll.statId)?.gear_max ?? 0} onchange={(event) => roll.value = clamp(event.currentTarget.value,attribute(roll.statId)?.gear_min,attribute(roll.statId)?.gear_max)}/></label><button aria-label={`Remove attribute ${index + 1}`} onclick={() => selection.rolls = selection.rolls.filter((_, itemIndex) => itemIndex !== index)}>×</button></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</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>{attribute(augment.stat_attribute_id)?.name} · {unitValue(augment.value,augment.stat_attribute_id)}</small></span><em>{augment.unlock_cost} COST</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-field-grid"><label>TIER<select value={selection.tier} onchange={(event) => changeWeaponTier(index,Number(event.currentTarget.value))}>{#each item.ranges as range}<option value={range.tier}>TIER {range.tier}</option>{/each}</select></label><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}<div class="builder-range-grid weapon">{#each weaponRangeKeys as key}<label>{key.toUpperCase()} <span>{range[`${key}_min`]}–{range[`${key}_max`]}</span><input type="number" min={range[`${key}_min`]} max={range[`${key}_max`]} value={selection[key]} onchange={(event) => selection[key] = clamp(event.currentTarget.value,range[`${key}_min`],range[`${key}_max`])}/></label>{/each}</div>{/if}
<div class="builder-field-grid">{#if item.talent1Ids.length}<label>TALENT 1<select bind:value={selection.talent1Id}>{#each item.talent1Ids as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/if}{#if item.talent2Ids.length}<label>TALENT 2<select bind:value={selection.talent2Id}>{#each item.talent2Ids as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/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)}
<div class="loadout-editor-head"><div><span>SKILL SYSTEM</span><h2>SKILL CHIP 0{index + 1}</h2></div><b>{chip.quality.toUpperCase()}</b></div>
<BuilderPicker items={data.chipSets} bind:value={chip.setId} onchange={(id) => configureChip(index,id)} label="SKILL CHIP SET"/>
{#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="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"><input type="checkbox" checked={draft.activeTalents.includes(equipped.key)} onchange={() => toggleTalent(equipped.key)}/><span><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source} · {draft.activeTalents.includes(equipped.key) ? 'TRIGGER ACTIVE' : 'CONDITIONAL'}</small><em>{equipped.description}</em></span></label>{:else}<p>Selected equipment talents appear here.</p>{/each}</div>
</div>
<p class="notice">Talent effects enter totals only while marked trigger active. Anonymous drafts remain on this device.</p>
</aside>
</div> </div>
</main> </main>