feat: promote command grid builder

This commit is contained in:
jb
2026-07-29 20:18:58 -04:00
parent a434cbe60c
commit f1b473ffa8
7 changed files with 355 additions and 25 deletions
+9 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,123 @@
<script lang="ts">
import ItemIcon from '$lib/components/ItemIcon.svelte';
export type MatrixCell = {
id: string;
label: string;
name: string;
meta: string;
summary: string;
iconUrl?: string;
};
let {
active,
doctrine,
abilities,
weapons,
gear,
protocol,
configured,
onselect
}: {
active: string;
doctrine: MatrixCell;
abilities: MatrixCell[];
weapons: MatrixCell[];
gear: MatrixCell[];
protocol: MatrixCell;
configured: boolean;
onselect: (id: string) => void;
} = $props();
const selected = (cell: MatrixCell) =>
active === cell.id
|| (cell.id.startsWith('ability:') && active.startsWith('chip:')
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
</script>
<section class="loadout-matrix" aria-label="Loadout matrix">
<header>
<span>LOADOUT MATRIX</span>
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b>
</header>
<div class="matrix-section doctrine-section">
<small class="section-label">DOCTRINE · ROOT CHOICE</small>
<button
type="button"
class="doctrine-cell"
class:active={selected(doctrine)}
aria-pressed={selected(doctrine)}
onclick={() => onselect(doctrine.id)}
>
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em></span>
<i aria-hidden="true"></i>
</button>
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
<div class="ability-cells">
{#each abilities as cell}
<button type="button" class:active={selected(cell)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em></span>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">WEAPONS · PRIMARY + SECONDARY</small>
<div class="equipment-cells">
{#each weapons as cell}
<button type="button" class:active={selected(cell)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em></span>
<i aria-hidden="true"></i>
</button>
{/each}
</div>
</div>
<div class="matrix-section">
<small class="section-label">GEAR · 6 POSITIONS</small>
<div class="equipment-cells gear-cells">
{#each gear as cell}
<button type="button" class:active={selected(cell)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em></span>
<i aria-hidden="true"></i>
</button>
{/each}
</div>
</div>
<div class="matrix-section protocol-section">
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
<button type="button" class:active={selected(protocol)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em></span>
<i aria-hidden="true"></i>
</button>
</div>
</section>
<style>
.loadout-matrix{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
.loadout-matrix>header{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;border-bottom:1px solid var(--shd-border);background:#0c0f10;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}
.loadout-matrix>header b{color:var(--shd-warning);font-size:9px;text-align:right}.loadout-matrix>header b.ready{color:var(--shd-success)}
.matrix-section{padding:5px;border-bottom:1px solid var(--shd-border)}.matrix-section:last-child{border-bottom:0}
.section-label{display:block;padding:2px 3px 5px;color:var(--shd-text-dim);font-size:9px;font-weight:900;letter-spacing:.09em}
.doctrine-cell,.equipment-cells button,.ability-cells button,.protocol-section>button{min-width:0;display:grid;grid-template-columns:34px minmax(0,1fr) 3px;align-items:center;gap:7px;padding:7px;border:1px solid var(--shd-border-soft);background:var(--shd-builder-canvas);color:var(--shd-text);text-align:left}
.doctrine-cell{width:100%;min-height:76px;grid-template-columns:42px minmax(0,1fr) 3px}.doctrine-cell :global(.matrix-icon){width:40px;height:40px}
:global(.matrix-icon){width:30px;height:30px;object-fit:contain}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
.ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px}
.equipment-cells{display:grid;grid-template-columns:1fr 1fr;gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px}
button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px}
@media(max-width:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}}
@media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}}
</style>
+18 -7
View File
@@ -4,6 +4,7 @@ import { getSessionUser } from '$lib/server/auth/session';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import { normalizeUserMarkdown } from '$lib/markdown';
import type { Actions, PageServerLoad } from './$types';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
@@ -27,10 +28,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
let initialBuild = null;
if (requestedSlug && viewer) {
const row = await (await database.prepare(
'SELECT id,title,slug,visibility,loadout_json FROM builds WHERE slug=? AND owner_id=?'
'SELECT id,title,slug,visibility,summary,guide_body,loadout_json FROM builds WHERE slug=? AND owner_id=?'
)).get(requestedSlug, viewer.id);
const loadout = typeof row?.loadout_json === 'string' ? parseLoadout(row.loadout_json) : null;
if (row && loadout) initialBuild = { id: String(row.id), slug: String(row.slug), visibility: String(row.visibility), loadout };
if (row && loadout) initialBuild = {
id: String(row.id),
slug: String(row.slug),
visibility: String(row.visibility),
summary: String(row.summary ?? ''),
guideBody: String(row.guide_body ?? ''),
loadout
};
}
const [
attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects,
@@ -239,7 +247,10 @@ export const actions: Actions = {
const loadout = parseLoadout(formData.get('loadout'));
const visibility = String(formData.get('visibility') ?? 'private');
const id = String(formData.get('id') ?? '').trim();
const summary = normalizeUserMarkdown(formData.get('summary'), 500);
const guideBody = normalizeUserMarkdown(formData.get('guideBody'), 12_000);
if (!loadout) return fail(400, { buildError: 'The loadout data is invalid. Refresh the builder and try again.' });
if (summary === null || guideBody === null) return fail(400, { buildError: 'Build notes contain unsupported characters or exceed their length limit.' });
const title = loadout.buildName.trim();
if (title.length < 2 || title.length > 100) return fail(400, { buildError: 'Build names must be between 2 and 100 characters.' });
if (!validVisibility.has(visibility)) return fail(400, { buildError: 'Choose a valid build visibility.' });
@@ -253,15 +264,15 @@ export const actions: Actions = {
if (!existing) return fail(404, { buildError: 'This saved build could not be found.' });
slug = String(existing.slug);
await (await database.prepare(
'UPDATE builds SET title=?,visibility=?,loadout_json=?,updated_at=? WHERE id=? AND owner_id=?'
)).run(title, visibility, JSON.stringify(loadout), now, buildId, user.id);
'UPDATE builds SET title=?,visibility=?,summary=?,guide_body=?,loadout_json=?,updated_at=? WHERE id=? AND owner_id=?'
)).run(title, visibility, summary, guideBody, JSON.stringify(loadout), now, buildId, user.id);
} else {
buildId = randomUUID();
slug = `${slugify(title)}-${buildId.slice(0, 8)}`;
await (await database.prepare(
`INSERT INTO builds(id,owner_id,title,slug,visibility,loadout_json,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`
)).run(buildId, user.id, title, slug, visibility, JSON.stringify(loadout), now, now);
`INSERT INTO builds(id,owner_id,title,slug,visibility,summary,guide_body,loadout_json,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`
)).run(buildId, user.id, title, slug, visibility, summary, guideBody, JSON.stringify(loadout), now, now);
}
return {
buildSaved: true,
+152 -11
View File
@@ -8,8 +8,10 @@
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';
@@ -22,9 +24,12 @@
let { data, form } = $props();
let draft = $state<LoadoutDraft>(untrack(() => parseLoadoutDraft(data.initialBuild?.loadout) ?? createDraft()));
let active = $state('gear:Holster');
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'));
@@ -382,6 +387,95 @@
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
});
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
};
}));
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)
};
}));
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)
};
}));
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
});
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 = [
@@ -402,6 +496,9 @@
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 {
@@ -429,6 +526,8 @@
});
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.');
}
@@ -438,15 +537,18 @@
<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={draft.buildName} aria-label="Build name"/></div>
<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>
@@ -466,18 +568,28 @@
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
</div>
{/if}
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}</span><span>18 EQUIPMENT POSITIONS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
{#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">
<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)} / 18 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"><button class:active={activeParts[0] === 'specialization' || activeParts[0] === 'chip'} onclick={() => active = 'specialization:focus'}><small>SPECIALIZATION FOCUS</small><strong>{selectedFocus ? `${selectedFocus.specialization.name} · ${selectedFocus.name}` : 'NOT CONFIGURED'}</strong><span>{selectedFocus ? `${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS · ${draft.chips.filter((chip) => chip.setId).length} / 9 CHIPS` : 'SELECT FOCUS'}</span></button><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>
<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'}
@@ -556,7 +668,36 @@
</div>
{/if}
{/if}
{:else if activeParts[0] === 'gear'}
{: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"/>
@@ -609,7 +750,7 @@
{: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 = 'specialization:focus'}>← RETURN TO SPECIALIZATION</button>
<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}
+4 -1
View File
@@ -54,7 +54,10 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
String(build.owner_id),
build.avatar_updated_at
),
owner: build.owner_id === viewer?.id, updatedAt: String(build.updated_at)
owner: build.owner_id === viewer?.id,
updatedAt: String(build.updated_at),
summary: String(build.summary ?? ''),
guideBody: String(build.guide_body ?? '')
},
loadout, gear, weapons, chips, protocols, brands, combatResults
};
+10
View File
@@ -1,13 +1,16 @@
<script lang="ts">
import { gearSlots } from '$lib/builder/loadout';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
import { renderUserMarkdown } from '$lib/markdown';
let { data } = $props();
const gear = (id: string) => data.gear.find((item) => item.id === id);
const weapon = (id: string) => data.weapons.find((item) => item.id === id);
const chip = (id: string) => data.chips.find((item) => item.id === id);
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
const brand = (id: string) => data.brands.find((item) => item.id === id);
const guideHtml = $derived(renderUserMarkdown(data.build.guideBody));
</script>
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
@@ -16,6 +19,13 @@
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
</header>
{#if data.build.summary || data.build.guideBody}
<section class="shared-build-guide" aria-labelledby="shared-guide-heading">
<header><span>FIELD GUIDE</span><b>AUTHOR NOTES</b></header>
{#if data.build.summary}<p class="shared-build-summary">{data.build.summary}</p>{/if}
{#if data.build.guideBody}<div><h2 id="shared-guide-heading">HOW TO RUN THIS BUILD</h2><MarkdownContent html={guideHtml}/></div>{/if}
</section>
{/if}
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
<div class="builder-section-title">
<div>