feat: promote command grid builder
This commit is contained in:
+39
-5
@@ -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
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>
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user