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
+39 -5
View File
@@ -332,6 +332,33 @@ squeezed beside long module headings on mobile.
- Community build cards retain the colored top rule, abstract loadout artwork,
concise tags, agent attribution, and a full-width terminal action.
### Command Grid builder
The production builder uses Command Grid as its loadout workbench. Keep the
entire build visible in a dense matrix while one connected editor configures
the selected cell. The full-width Specialization and Focus cell is the root
choice. Its three supplied abilities appear directly below it, followed by two
weapons, six gear positions, and one full-width OS Protocol.
Matrix cells summarize selected Talent names, Attribute names and values,
Brand Sets, or Skill Chips. Do not replace those choices with configuration
counts. Selecting an ability opens its three attached Skill Chip positions.
Selecting equipment opens the searchable real-data catalog and its compatible
configuration controls. Do not display a prototype option that the current
loadout schema cannot save.
Use `--shd-builder-canvas` for recessed equipment fields. Use
`--shd-builder-selection` with `--shd-builder-selection-border` for the
selected cell. Preserve button semantics, `aria-pressed`, focus-visible
outlines, pressed feedback, and textual readiness states. At narrower desktop
widths, keep the matrix and editor together and move live results below them.
On mobile, stack the matrix, editor, and live results in that order.
The command header contains the required build name, local and account save
actions, account visibility, and optional sharing notes. Sharing notes include
a short strengths summary and a Markdown usage guide. Persist them separately
from the validated loadout payload and render them on the shared build page.
### Prelaunch network shell
Use the standalone prelaunch shell for `/coming-soon`. It replaces the global
@@ -1615,6 +1642,12 @@ Scenario uses the source-defined advanced assumptions saved with the loadout.
Exclude a mechanic that lacks a required assumption and label it `REQUIRES
ASSUMPTIONS`. This distinction must remain clear without relying on color.
Loadout summaries name selected Talents, Attributes and their exact values,
nodes, and Skill Chips. Do not replace these selections with configured
item counts. Build identity includes a required name plus optional strengths
summary and usage guide. Shared build views include those author-written fields
so another player can understand the build's role and operating sequence.
### Pending ISAC registry records
Pending additions appear directly in the same paginated ISAC registry as live
@@ -2355,11 +2388,12 @@ required inputs.
### Specialization loadout composer
The builder places Specialization focus selection after equipment
configuration. The focus identity combines Specialization artwork and the
primary Attribute mark, followed by three ability panels. Each ability panel
contains three fixed Skill Chip positions. A chip picker shows definitions
assigned to that ability and excludes definitions assigned elsewhere.
The builder presents Specialization and Focus as the first loadout decision.
The Focus sets the primary Attribute, progression tree, and three ordered
abilities. Its identity combines Specialization artwork and the primary
Attribute mark. Each ability panel contains three fixed Skill Chip positions.
A chip picker shows definitions assigned to that ability and excludes
definitions assigned elsewhere.
The focus tree groups nodes by row and uses segmented rank controls. Disabled
ranks remain visible with their lock state. The builder permits a row after the
+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>