From 7bb4b92965973bc23be5ee392f71b655b0df2543 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 22:28:40 -0400 Subject: [PATCH] Add persisted and shareable builds --- database/schema.sql | 3 +- docs/DESIGN_SYSTEM.md | 16 +++++ scripts/db-migrate.mjs | 10 ++++ src/app.css | 1 + src/routes/account/+page.svelte | 2 +- src/routes/builder/+page.server.ts | 76 +++++++++++++++++++++++- src/routes/builder/+page.svelte | 58 ++++++++++++++++-- src/routes/builds/+page.server.ts | 20 +++++++ src/routes/builds/+page.svelte | 25 ++++++-- src/routes/builds/[slug]/+page.server.ts | 34 +++++++++++ src/routes/builds/[slug]/+page.svelte | 32 ++++++++++ 11 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 src/routes/builds/+page.server.ts create mode 100644 src/routes/builds/[slug]/+page.server.ts create mode 100644 src/routes/builds/[slug]/+page.svelte diff --git a/database/schema.sql b/database/schema.sql index 343d186..3a0b083 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -69,7 +69,8 @@ CREATE TABLE IF NOT EXISTS notification_preferences ( ); CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC); CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC); -CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'draft', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), loadout_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility, updated_at DESC); CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL); CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id)); CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id), body TEXT NOT NULL, created_at TEXT NOT NULL); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 2819c44..480a5e1 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1155,3 +1155,19 @@ back to a short orange text mark rather than removing the relationship. Use this pattern only for small bounded sets that remain scannable, such as the two-to-four compatible Brand Sets on Gear. Long or unbounded relationships continue to use counts, searchable inspection, or expanded detail views. + +### Build persistence and sharing + +The builder distinguishes device persistence from account persistence. `SAVE +LOCALLY` writes a working draft to the current browser. Signed-in agents also +receive one account-save action paired with an explicit visibility selector: + +- `PRIVATE` is owner-only. +- `UNLISTED` is accessible by direct link but excluded from discovery. +- `PUBLIC` is accessible by direct link and included in the public archive. + +Keep visibility adjacent to the account-save action and explain its selected +state in plain language. Show a share-page action only after a non-private +build has been saved. Public and unlisted build pages are read-only tactical +summaries; owner editing returns to the builder. On narrow layouts, +persistence controls and shared-loadout columns stack vertically. diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index ac92573..e906e4e 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -255,6 +255,16 @@ for (const [name, definition] of [ } } +const buildColumns = await (await database.prepare("PRAGMA table_info('builds')")).all(); +if (buildColumns.length && !buildColumns.some((column) => column.name === 'loadout_json')) { + console.log('Adding persistent builder loadout snapshots.'); + await database.exec('ALTER TABLE builds ADD COLUMN loadout_json TEXT'); +} +if (buildColumns.length) { + await database.exec("UPDATE builds SET visibility='private' WHERE visibility='draft'"); +} +await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON builds(visibility,updated_at DESC)'); + console.log(`Applied ${statements.length} schema statements to ${databasePath}`); if (!databaseAlreadyExisted) { diff --git a/src/app.css b/src/app.css index 83d40e9..714d5f0 100644 --- a/src/app.css +++ b/src/app.css @@ -190,4 +190,5 @@ .profile-metrics>a{min-width:77px;height:59px;align-items:center;padding:7px;border:1px solid var(--shd-border);background:#111415} .notification-command{align-items:center}.notification-command>div:first-child{display:grid;align-content:center}.notification-command>div:last-child{align-self:center}.notification-preference-head>span:not(:first-child){text-align:center}.notification-preference-row>label{justify-content:center}.notification-list>article{grid-template-columns:190px minmax(0,1fr);transition:border-color .14s ease,background .14s ease}.notification-list>article.isac-message{border-left:2px solid #68a9c4;background:linear-gradient(90deg,#101a1e 0,#101314 34%)}.notification-list>article.positive{border-left-color:var(--shd-success)}.notification-list>article.negative{border-left-color:var(--shd-danger)}.notification-list>article.positive:not(.isac-message){background:linear-gradient(90deg,#101914 0,#101314 34%)}.notification-list>article.negative:not(.isac-message){background:linear-gradient(90deg,#1b1111 0,#101314 34%)}.notification-source{grid-template-columns:38px minmax(0,1fr);align-items:center;align-content:center;gap:9px}.notification-source>img{width:38px;height:38px;object-fit:cover;border:1px solid #3a4548;border-radius:50%;background:#111}.notification-source>span{min-width:0;display:grid;gap:4px}.notification-source>span b,.notification-source>span small{overflow:hidden;text-overflow:ellipsis}.notification-source>span b{white-space:normal}.notification-source>span small{color:#667073;font-size:5px;white-space:nowrap}.notification-list article header>div{display:flex;align-items:center;gap:8px;min-width:0}.notification-tone{flex:0 0 auto;padding:3px 5px;border:1px solid #50585a;color:#899193;font-size:5px;font-weight:900;letter-spacing:.6px}.positive .notification-tone{border-color:#41684b;color:var(--shd-success)}.negative .notification-tone{border-color:#6d3f39;color:var(--shd-danger)}.isac-message .notification-source b{color:#8ecce5}.notification-list article footer>button:first-child{color:var(--shd-orange)}.notification-detail-modal{width:min(720px,calc(100vw - 24px));border-top:3px solid #687174}.notification-detail-modal.isac-message{border-top-color:#68a9c4}.notification-detail-modal.positive{border-top-color:var(--shd-success)}.notification-detail-modal.negative{border-top-color:var(--shd-danger)}.notification-detail-sender{display:grid;grid-template-columns:48px 1fr 34px;align-items:center;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--shd-border)}.notification-detail-sender>img{width:48px;height:48px;object-fit:cover;border:1px solid #485052;border-radius:50%;background:#111}.notification-detail-sender>div{display:grid;gap:4px}.notification-detail-sender span{color:#697174;font-size:6px;letter-spacing:1px}.notification-detail-sender b{font-size:10px}.notification-detail-sender>button{height:34px;border:1px solid var(--shd-border);background:#111415;color:#aeb4b5;cursor:pointer}.notification-detail-state{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:12px;margin:10px 0;padding:7px 9px;border:1px solid var(--shd-border);background:#0d1011}.notification-detail-state span,.notification-detail-state time{color:#6f7779;font-size:6px;letter-spacing:.7px}.notification-detail-state b{color:#899193;font-size:7px}.notification-detail-modal.positive .notification-detail-state b{color:var(--shd-success)}.notification-detail-modal.negative .notification-detail-state b{color:var(--shd-danger)}.notification-detail-modal h2{margin:15px 0 8px;font-size:20px}.notification-detail-modal>p{min-height:90px;margin:0;padding:13px;border-left:2px solid var(--shd-border);background:#0e1112;color:#c0c5c6;font:13px/1.7 Georgia,serif}.notification-detail-modal.isac-message>p{border-left-color:#68a9c4}.notification-detail-modal.positive>p{border-left-color:var(--shd-success)}.notification-detail-modal.negative>p{border-left-color:var(--shd-danger)}.notification-detail-modal .form-actions{align-items:center}.notification-detail-modal .form-actions form{margin-right:auto}.notification-detail-modal .form-actions .button{min-height:40px}@media(max-width:700px){.notification-list>article{grid-template-columns:1fr}.notification-source{display:grid;grid-template-columns:38px 1fr;justify-content:initial}.notification-list article header>div{align-items:flex-start;flex-direction:column}.notification-detail-state{grid-template-columns:1fr auto}.notification-detail-state time{grid-column:1/3}.notification-detail-modal .form-actions{display:grid;grid-template-columns:1fr}.notification-detail-modal .form-actions form,.notification-detail-modal .form-actions button,.notification-detail-modal .form-actions a{width:100%;margin:0}} .notification-command{padding:0 0 18px}.notification-command-actions{display:flex!important;align-items:stretch!important;gap:8px!important}.notification-unread-summary{min-width:86px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:7px;padding:7px 11px;border:1px solid var(--shd-border);background:#0d1011}.notification-unread-summary strong{grid-row:auto!important;font-size:25px!important;line-height:1}.notification-unread-summary span{align-self:center;line-height:1.2}.notification-command-actions>button,.notification-toolbar button{min-height:40px;display:inline-flex;align-items:center;justify-content:center;margin:0!important;padding:0 18px;line-height:1;white-space:nowrap}.notification-command-actions>button>span{font-size:10px}.notification-toolbar{align-items:center;padding:10px 0}.notification-toolbar form{align-items:center;margin:0}.notification-toolbar select{min-width:125px;height:40px}.notification-toolbar form:first-child button{min-width:150px}.notification-toolbar>form:last-child{margin-left:auto}@media(max-width:700px){.notification-command-actions{width:100%;display:grid!important;grid-template-columns:auto 1fr}.notification-command-actions>button{width:100%}.notification-toolbar{align-items:stretch}.notification-toolbar>form:last-child{margin-left:0}} +.profile-row>a{color:var(--shd-orange);font-size:8px;font-weight:700}.builder-save-form{display:inline-block}.builder-save-form fieldset{display:flex}.builder-persistence{display:grid;grid-template-columns:minmax(300px,520px) 1fr auto;gap:14px;align-items:end;padding:11px 2px;border-top:1px solid var(--shd-border)}.builder-persistence p{margin:0 0 9px;color:var(--shd-text-muted);font-size:9px}.builder-share-link{align-self:center;color:var(--shd-orange);font-size:8px;font-weight:700;letter-spacing:1px}.catalogue-empty{grid-column:1/-1;display:grid;justify-items:center;gap:12px;padding:60px;border:1px solid var(--shd-border);background:var(--shd-panel);text-align:center}.catalogue-empty>span{color:var(--shd-orange);font-size:34px}.catalogue-empty p{margin:0;color:var(--shd-text-muted)}.build-open{display:block;text-align:center}.shared-build-page{padding-top:28px}.shared-build-command{display:flex;justify-content:space-between;gap:24px;align-items:end;padding:22px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-panel)}.shared-build-command h1{margin:8px 0;font-size:clamp(30px,4vw,52px)}.shared-build-command p:last-child{margin:0;color:var(--shd-text-muted);font-size:10px}.shared-build-command>div:last-child{display:flex;gap:8px}.shared-build-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:10px}.shared-build-grid>section{border:1px solid var(--shd-border);background:var(--shd-panel)}.shared-build-grid>section>header{display:flex;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:8px;letter-spacing:1.2px}.shared-build-grid article{display:grid;gap:4px;min-height:70px;padding:12px;border-bottom:1px solid var(--shd-border)}.shared-build-grid article:last-child{border-bottom:0}.shared-build-grid article small{color:var(--shd-text-muted);font-size:7px;letter-spacing:1px}.shared-build-grid article strong{font-size:12px}.shared-build-grid article span{color:var(--shd-text-muted);font-size:8px}@media(max-width:760px){.builder-command .page-title,.shared-build-command{align-items:stretch;flex-direction:column}.builder-command .actions,.shared-build-command>div:last-child{flex-wrap:wrap}.builder-persistence{grid-template-columns:1fr}.shared-build-grid{grid-template-columns:1fr}} diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index cf9a52e..3d9a2db 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -97,7 +97,7 @@

LOADOUT ARCHIVE

MY BUILDS

NEW BUILD +
- {#if data.account.builds.length}
LOADOUTVISIBILITYUPDATED
{#each data.account.builds as build}
{build.title}/{build.slug}
{build.visibility}{new Date(String(build.updated_at)).toLocaleDateString()}
{/each}
{:else}
◇NO BUILDS RECORDED

Your saved and published loadouts will appear here.

CREATE YOUR FIRST BUILD →
{/if} + {#if data.account.builds.length}
LOADOUTVISIBILITYUPDATED
{#each data.account.builds as build}
{build.title}/{build.slug}
{build.visibility}{new Date(String(build.updated_at)).toLocaleDateString()}EDIT →
{/each}
{:else}
◇NO BUILDS RECORDED

Your saved and published loadouts will appear here.

CREATE YOUR FIRST BUILD →
{/if}

SAVED INTELLIGENCE

FAVORITES

{data.account.favorites.length}SAVED LOADOUTS

FIELD FEEDBACK

RATINGS

{data.account.ratings.length}RATED LOADOUTS
diff --git a/src/routes/builder/+page.server.ts b/src/routes/builder/+page.server.ts index f334e54..708e2c5 100644 --- a/src/routes/builder/+page.server.ts +++ b/src/routes/builder/+page.server.ts @@ -1,9 +1,39 @@ import { getDatabase } from '$lib/server/database'; import { gameDataIconUrl } from '$lib/server/game-data-icons'; -import type { PageServerLoad } from './$types'; +import { getSessionUser } from '$lib/server/auth/session'; +import { fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { Actions, PageServerLoad } from './$types'; -export const load: PageServerLoad = async () => { +const validVisibility = new Set(['private', 'unlisted', 'public']); +const slugify = (value: string) => value.toLowerCase().normalize('NFKD') + .replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-').replace(/-+/g, '-').slice(0, 60) || 'loadout'; + +function parseLoadout(raw: FormDataEntryValue | null) { + if (typeof raw !== 'string' || raw.length > 250_000) return null; + try { + const value = JSON.parse(raw); + if (value?.version !== 1 || typeof value.buildName !== 'string' || !value.gear || + !Array.isArray(value.weapons) || value.weapons.length !== 2 || + !Array.isArray(value.chips) || value.chips.length !== 3 || !value.protocol) return null; + return value; + } catch { + return null; + } +} + +export const load: PageServerLoad = async ({ cookies, url }) => { const database = await getDatabase(); + const viewer = await getSessionUser(cookies); + const requestedSlug = url.searchParams.get('build'); + 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=?' + )).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 }; + } const [ attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects, weapons, weaponRanges, weaponTalentOptions, weaponBonuses, @@ -57,6 +87,8 @@ export const load: PageServerLoad = async () => { })) })); return { + viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null, + initialBuild, attributes, coreRanges, gear: gear.map((item) => ({ @@ -105,3 +137,43 @@ export const load: PageServerLoad = async () => { }) }; }; + +export const actions: Actions = { + save: async ({ cookies, request }) => { + const user = await getSessionUser(cookies); + if (!user) return fail(401, { buildError: 'Sign in to save and share this loadout.' }); + const formData = await request.formData(); + const loadout = parseLoadout(formData.get('loadout')); + const visibility = String(formData.get('visibility') ?? 'private'); + const id = String(formData.get('id') ?? '').trim(); + if (!loadout) return fail(400, { buildError: 'The loadout data is invalid. Refresh the builder and try again.' }); + 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.' }); + + const database = await getDatabase(); + const now = new Date().toISOString(); + let buildId = id; + let slug = ''; + if (buildId) { + const existing = await (await database.prepare('SELECT id,slug FROM builds WHERE id=? AND owner_id=?')).get(buildId, user.id); + 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); + } 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); + } + return { + buildSaved: true, + buildToken: randomUUID(), + savedBuild: { id: buildId, slug, visibility, title } + }; + } +}; diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index 8dd80de..43bf00a 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -1,21 +1,26 @@ + Discover builds — SHD.build -

SHD LOADOUT ARCHIVE

DISCOVER
BUILDS.

Field-tested configurations submitted by agents across Manhattan.

2,847PUBLIC LOADOUTSNEW BUILD +
{#each builds as build}
{build.mode}★ {build.rating}
SHD LOADOUT

COMMUNITY BUILD

{build.title}

{#each build.tags as tag}{tag}{/each}
{build.author.slice(0,1)}BY {build.author}{build.votes} RATINGS
{/each}
+
+
+

SHD LOADOUT ARCHIVE

DISCOVER
BUILDS.

Field-tested configurations submitted by registered agents.

+
{data.builds.length}PUBLIC LOADOUTSNEW BUILD +
+
+
+ {#each data.builds as build} +
+
PUBLIC{Number(build.votes) ? `★ ${Number(build.rating).toFixed(1)}` : 'UNRATED'}
+
SHD LOADOUT
+

COMMUNITY BUILD

{build.title}

{String(build.author).slice(0,1)}BY {build.author}{build.votes} RATINGS
VIEW LOADOUT →
+
+ {:else} +
◇NO PUBLIC BUILDS YET

Be the first agent to publish a loadout to the archive.

CREATE BUILD →
+ {/each} +
+
diff --git a/src/routes/builds/[slug]/+page.server.ts b/src/routes/builds/[slug]/+page.server.ts new file mode 100644 index 0000000..1a1ca26 --- /dev/null +++ b/src/routes/builds/[slug]/+page.server.ts @@ -0,0 +1,34 @@ +import { error } from '@sveltejs/kit'; +import { getDatabase } from '$lib/server/database'; +import { getSessionUser } from '$lib/server/auth/session'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, cookies }) => { + const database = await getDatabase(); + const viewer = await getSessionUser(cookies); + const build = await (await database.prepare(` + SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author + FROM builds + JOIN users ON users.id=builds.owner_id + LEFT JOIN user_profiles ON user_profiles.user_id=users.id + WHERE builds.slug=? + `)).get(params.slug); + if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found'); + let loadout; + try { loadout = JSON.parse(String(build.loadout_json)); } catch { error(404, 'Build data is unavailable'); } + const [gear, weapons, chips, protocols, brands] = await Promise.all([ + (await database.prepare('SELECT id,name,slot,tier FROM gear')).all(), + (await database.prepare('SELECT id,name,category,is_exotic FROM weapons')).all(), + (await database.prepare('SELECT id,name,quality FROM skill_chip_sets')).all(), + (await database.prepare('SELECT id,name,quality FROM os_protocols')).all(), + (await database.prepare('SELECT id,name FROM brand_sets')).all() + ]); + return { + build: { + id: String(build.id), title: String(build.title), slug: String(build.slug), + visibility: String(build.visibility), author: String(build.author), + owner: build.owner_id === viewer?.id, updatedAt: String(build.updated_at) + }, + loadout, gear, weapons, chips, protocols, brands + }; +}; diff --git a/src/routes/builds/[slug]/+page.svelte b/src/routes/builds/[slug]/+page.svelte new file mode 100644 index 0000000..3fa7e07 --- /dev/null +++ b/src/routes/builds/[slug]/+page.svelte @@ -0,0 +1,32 @@ + + +{data.build.title} — SHD.build +
+
+

SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}

{data.build.title}

Filed by {data.build.author} · Updated {new Date(data.build.updatedAt).toLocaleDateString()}

+
{#if data.build.owner}EDIT LOADOUT{/if}NEW BUILD +
+
+
+
+
WEAPONS02 SLOTS
+ {#each data.loadout.weapons as selection, index}{@const item=weapon(selection.itemId)}
{index ? 'SECONDARY' : 'PRIMARY'}{item?.name ?? 'EMPTY SLOT'}{item ? `${item.category} · T${selection.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}
{/each} +
+
+
GEAR06 SLOTS
+ {#each gearSlots as slot}{@const selection=data.loadout.gear[slot]}{@const item=gear(selection.itemId)}
{slot.toUpperCase()}{item?.name ?? 'EMPTY SLOT'}{item ? `${brand(selection.brandId)?.name ?? 'NO BRAND'} · T${item.tier} · LEVEL ${selection.level}` : 'NOT CONFIGURED'}
{/each} +
+
+
AGENT SYSTEMS04 SLOTS
+ {#each data.loadout.chips as selection, index}{@const item=chip(selection.setId)}
SKILL CHIP 0{index+1}{item?.name ?? 'EMPTY SLOT'}{item ? selection.quality : 'NOT CONFIGURED'}
{/each} + {#if protocol(data.loadout.protocol.itemId)}{@const protocolItem=protocol(data.loadout.protocol.itemId)}
OS PROTOCOL{protocolItem.name}LEVEL {data.loadout.protocol.level} · {data.loadout.protocol.talentUpgrades}/5 UPGRADES
{:else}
OS PROTOCOLEMPTY SLOTNOT CONFIGURED
{/if} +
+
+