From 095b697b08db393fc40684eceef089a17b3d2211 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 16:46:01 -0400 Subject: [PATCH] Add shared gear tactical augment profiles --- database/schema.sql | 16 +++++++ docs/DESIGN_SYSTEM.md | 16 +++++++ src/app.css | 3 ++ src/routes/admin/gear/+page.server.ts | 65 ++++++++++++++++++++++++++- src/routes/admin/gear/+page.svelte | 58 +++++++++++++++++++++++- 5 files changed, 156 insertions(+), 2 deletions(-) diff --git a/database/schema.sql b/database/schema.sql index 4678db5..f84982f 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -382,6 +382,22 @@ CREATE TABLE IF NOT EXISTS gear_talents ( talent_id TEXT NOT NULL REFERENCES talents(id), PRIMARY KEY(gear_id, talent_id) ); +CREATE TABLE IF NOT EXISTS gear_tactical_augments ( + id TEXT PRIMARY KEY, + gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), + augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4), + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + value REAL NOT NULL, + unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(gear_slot, augment_slot), + CHECK( + (gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) + OR + (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3) + ) +); CREATE TABLE IF NOT EXISTS weapons ( id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 21509c9..702df74 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -798,3 +798,19 @@ augmentation rows: its selected record and search field remain inline while the filtered result registry opens as an anchored overlay. The compact overlay must layer above adjacent rows, retain pending-record labels, and collapse to one column on narrow screens. + +### Shared equipment profiles + +Configuration inherited by every item of a finite equipment type belongs in a +collapsible shared-profile registry above the item table. Each profile card +states its type, required slot count, configured state, and a compact summary; +selecting it opens a separate dense editor and closes the individual-record +editor. Successful saves close the profile editor and return to the registry. + +Gear Tactical Augment profiles use this pattern for all six gear types. +Backpacks and Body Armor expose four required slots; Holsters, Gloves, Knee +Pads, and Masks expose three. Every slot contains exactly one searchable +Attribute relationship, one numeric effect value, and one non-negative +whole-number unlock cost. At narrow widths profile cards and augment editor +cards reduce from multi-column grids to a single column while retaining +44-pixel interactive targets. diff --git a/src/app.css b/src/app.css index 316966a..1b16ea2 100644 --- a/src/app.css +++ b/src/app.css @@ -139,6 +139,9 @@ .command-status .data-mode-status.oversight{border-left-color:#68a9c4}.command-status .data-mode-status.oversight strong{color:#8ecce5}.oversight-decision-tabs{grid-template-columns:repeat(3,1fr)}.proposal-correction-head{display:flex;align-items:center;justify-content:space-between;margin:10px 0 6px;padding:8px 9px;border:1px solid var(--shd-border);background:#0d1011}.proposal-correction-head>div{display:grid;gap:3px}.proposal-correction-head span{font-size:7px;font-weight:900;letter-spacing:.8px}.proposal-correction-head small{color:var(--shd-text-muted);font-size:6px}.proposal-correction-head>b{color:var(--shd-orange);font-size:7px}.proposal-correction-grid{max-height:340px;display:grid;grid-template-columns:1fr 1fr;gap:6px;overflow:auto;padding:7px;border:1px solid var(--shd-border);background:#0b0e0f}.proposal-correction-grid .field{margin:0}.proposal-correction-grid .field>span{display:flex;justify-content:space-between}.proposal-correction-grid .field>span small{color:#61696b;font-size:5px}.proposal-correction-grid textarea{min-height:74px;resize:vertical}.immutable-proposal-file{display:grid;align-content:center;gap:4px;min-height:54px;padding:8px;border:1px dashed #394041;color:var(--shd-text-muted)}.immutable-proposal-file span,.immutable-proposal-file small{font-size:6px}.immutable-proposal-file b{color:var(--shd-text);font-size:8px}@media(max-width:700px){.oversight-decision-tabs,.proposal-correction-grid{grid-template-columns:1fr}} .mobile-nav-toggle,.mobile-nav-layer{display:none}@media(max-width:900px){.site-header{position:relative;z-index:40;padding:0 16px}.site-header>.account,.site-header>.sign-in{display:none}.mobile-nav-toggle{width:52px;height:44px;display:grid;grid-template-columns:1fr;align-content:center;gap:4px;margin-left:auto;padding:6px 8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text);cursor:pointer}.mobile-nav-toggle>span{width:20px;height:1px;margin-left:auto;background:var(--shd-orange);transition:transform .14s ease,opacity .14s ease}.mobile-nav-toggle>b{font-size:5px;letter-spacing:1px;text-align:right}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(1){transform:translateY(5px) rotate(45deg)}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(2){opacity:0}.mobile-nav-toggle[aria-expanded=true]>span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.mobile-nav-toggle:hover,.mobile-nav-toggle:focus-visible{border-color:var(--shd-orange);background:#171b1c}.mobile-nav-toggle:active{transform:translateY(1px);filter:brightness(.86)}.mobile-nav-layer{position:fixed;z-index:35;inset:76px 0 0;display:grid;grid-template-columns:1fr minmax(270px,82vw)}.mobile-nav-backdrop{grid-column:1;grid-row:1;width:100%;height:100%;padding:0;border:0;background:#050607c9;backdrop-filter:blur(2px);cursor:pointer}.mobile-navigation{grid-column:2;grid-row:1;width:100%;height:100%;display:flex;flex-direction:column;margin:0;overflow-y:auto;border-left:1px solid #4a5051;background:#0d1011;box-shadow:-18px 0 40px #000b}.mobile-nav-heading{min-height:61px;display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-bottom:1px solid var(--shd-border);background:#111415}.mobile-nav-heading>div{display:grid;gap:4px}.mobile-nav-heading span{color:var(--shd-text-muted);font-size:6px;letter-spacing:1.2px}.mobile-nav-heading strong{font-size:13px;letter-spacing:1px}.mobile-nav-heading>b{color:var(--shd-success);font-size:6px;letter-spacing:1px}.mobile-navigation>a{min-height:54px;display:grid;grid-template-columns:42px 1fr 20px;align-items:center;padding:0 13px 0 0;border-bottom:1px solid var(--shd-border-soft);color:#a1a8a9}.mobile-navigation>a small{height:100%;display:grid;place-items:center;background:#111415;color:#555d5f;font-size:6px;letter-spacing:.7px}.mobile-navigation>a span{padding-left:13px;font-size:10px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.mobile-navigation>a>b{color:var(--shd-orange);font-size:18px}.mobile-navigation>a:hover,.mobile-navigation>a:focus-visible,.mobile-navigation>a.active{background:#171b1c;color:#fff}.mobile-navigation>a.active{box-shadow:inset 2px 0 var(--shd-orange)}.mobile-navigation>a.active small{background:#211a0e;color:var(--shd-orange)}.mobile-navigation>a:active{filter:brightness(.82)}.mobile-agent-state{display:grid;gap:4px;margin-top:auto;padding:14px;border-top:1px solid var(--shd-border)}.mobile-agent-state span{color:#747c7e;font-size:6px;letter-spacing:1px}.mobile-agent-state i{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--shd-success);box-shadow:0 0 6px var(--shd-success)}.mobile-agent-state strong{font-size:11px}.mobile-navigation form{margin:0;padding:0 14px 14px}.mobile-sign-out{width:100%;min-height:40px;border:1px solid #503b37;background:#1b1211;color:var(--shd-danger);font-size:7px;font-weight:900;letter-spacing:1px;cursor:pointer}.mobile-sign-out:hover,.mobile-sign-out:focus-visible{border-color:var(--shd-danger);background:#251715}.mobile-sign-out:active{transform:translateY(1px);filter:brightness(.86)}}@media(max-width:430px){.mobile-nav-layer{grid-template-columns:36px 1fr}.site-header .brand-name small{display:none}}@media(prefers-reduced-motion:reduce){.mobile-nav-toggle>span{transition:none}} @media(max-width:700px){.module-toolbar{align-items:stretch;flex-direction:column;gap:11px;padding-bottom:11px}.module-toolbar>div:first-child{width:100%}.module-toolbar>button{width:100%;min-height:48px;justify-content:center}.module-toolbar>div:last-child:has(button){width:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:7px}.module-toolbar>div:last-child:has(button)>button{width:100%;min-height:48px;padding:0 14px;font-size:8px;letter-spacing:1px}.module-toolbar .primary,.module-toolbar .secondary{height:auto;min-height:48px}} + +/* Gear shared Tactical Augment profiles */ +.gear-augment-registry{margin:10px 0;border:1px solid var(--shd-border);background:#0d1011}.gear-augment-registry>summary{margin:0;padding:9px 10px;cursor:pointer;list-style:none}.gear-augment-registry>summary::-webkit-details-marker{display:none}.gear-augment-registry>summary:hover,.gear-augment-registry>summary:focus-visible{background:#171b1c}.gear-augment-registry>summary:active{transform:translateY(1px);filter:brightness(.88)}.gear-augment-registry>summary>div:last-child{display:flex;align-items:center;gap:10px}.gear-augment-registry>summary i{color:var(--shd-orange);font-size:13px;font-style:normal;transition:transform .16s ease}.gear-augment-registry[open]>summary{border-bottom:1px solid var(--shd-border);background:#131718}.gear-augment-registry[open]>summary i{transform:rotate(45deg)}.gear-augment-profile-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;padding:8px}.gear-augment-profile-grid>button{min-width:0;padding:8px;border:1px solid var(--shd-border);background:#111415;color:var(--shd-text-muted);text-align:left;cursor:pointer}.gear-augment-profile-grid>button:hover,.gear-augment-profile-grid>button:focus-visible{border-color:#8a672b;background:#171b1c}.gear-augment-profile-grid>button:active{transform:translateY(1px);filter:brightness(.88)}.gear-augment-profile-grid>button.configured{border-left:2px solid var(--shd-success)}.gear-augment-profile-grid header{display:flex;justify-content:space-between;align-items:center;padding-bottom:6px;border-bottom:1px solid #292e2f}.gear-augment-profile-grid header div{display:grid;gap:2px}.gear-augment-profile-grid header strong{color:#d7d9d5;font-size:9px}.gear-augment-profile-grid header small,.gear-augment-profile-grid header>span{color:#626a6c;font-size:6px;letter-spacing:.6px}.gear-augment-profile-grid>button.configured header>span{color:var(--shd-success)}.gear-augment-profile-grid>button>div{display:grid;margin-top:5px}.gear-augment-profile-grid>button>div span{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:6px;padding:5px 2px;border-top:1px solid #232829}.gear-augment-profile-grid>button>div span:first-child{border-top:0}.gear-augment-profile-grid>button>div small{color:var(--shd-orange);font-size:6px}.gear-augment-profile-grid>button>div b{overflow:hidden;color:#aeb4b5;font-size:7px;text-overflow:ellipsis;white-space:nowrap}.gear-augment-profile-grid>button>div em{color:#626a6c;font-size:6px;font-style:normal}.gear-augment-editor{border-top-color:var(--shd-success)}.gear-augment-slots{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.gear-augment-slots section{min-width:0;padding:8px;border:1px solid var(--shd-border);background:#101314}.gear-augment-slots section>header{display:flex;justify-content:space-between;padding-bottom:6px;border-bottom:1px solid #2a2f30}.gear-augment-slots section>header strong{color:var(--shd-orange);font-size:7px;letter-spacing:.8px}.gear-augment-slots section>header span{color:#596164;font-size:6px}.gear-augment-slots .field{margin:7px 0 0}.gear-augment-slots .field>span,.gear-augment-slots label{font-size:7px}@media(max-width:1100px){.gear-augment-slots{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-augment-profile-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:560px){.gear-augment-profile-grid,.gear-augment-slots{grid-template-columns:1fr}.gear-augment-profile-grid>button{min-height:44px}} .mobile-row-open{display:none}@media(max-width:700px){.mobile-detail-row{position:relative;min-height:48px;padding-bottom:25px;cursor:pointer}.dashboard-row>.mobile-row-open{position:absolute;z-index:2;inset:0;width:100%;height:100%;display:block;justify-self:stretch;margin:0;padding:0;border:0;background:transparent;color:transparent;font-size:0;cursor:pointer}.mobile-detail-row>.row-action,.mobile-detail-row>button:not(.mobile-row-open){display:none}.mobile-detail-row::after{content:"TAP TO VIEW ›";position:absolute;right:10px;bottom:7px;color:var(--shd-orange);font-size:6px;font-weight:900;letter-spacing:.8px;pointer-events:none}.moderation-row.mobile-detail-row::after{content:"TAP TO REVIEW ›"}.mobile-detail-row:hover{border-color:#665026;background:#171713}.mobile-detail-row:has(.mobile-row-open:focus-visible){outline:2px solid var(--shd-orange);outline-offset:-2px}.mobile-detail-row:has(.mobile-row-open:active){transform:translateY(1px);filter:brightness(.86)}}@media(prefers-reduced-motion:reduce){.mobile-detail-row:has(.mobile-row-open:active){transform:none}} .avatar-review-filter{grid-template-columns:minmax(220px,1fr) auto}.avatar-review-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.avatar-review-card{min-width:0;display:grid;grid-template-columns:112px minmax(0,1fr);border:1px solid var(--shd-border);background:#111415}.avatar-review-image{position:relative;min-height:142px;display:grid;place-items:center;overflow:hidden;border-right:1px solid var(--shd-border);background:#0b0e0f}.avatar-review-image img{width:100%;height:100%;object-fit:cover}.avatar-review-image>span{padding:10px;color:#596164;font-size:6px;font-weight:900;line-height:1.5;text-align:center}.avatar-review-image>b{position:absolute;right:0;bottom:0;padding:5px 7px;border-top:1px solid currentColor;border-left:1px solid currentColor;background:#171b1ce8;color:#889092;font-size:6px;letter-spacing:.7px}.avatar-review-image>b.pending{color:var(--shd-orange)}.avatar-review-image>b.approved{color:var(--shd-success)}.avatar-review-image>b.declined{color:var(--shd-danger)}.avatar-review-details{min-width:0;display:grid;align-content:start;gap:4px;padding:9px}.avatar-review-details>strong{font-size:10px}.avatar-review-details>small{overflow:hidden;color:#697174;font-size:6px;text-overflow:ellipsis;white-space:nowrap}.avatar-review-details dl{display:grid;gap:4px;margin:5px 0}.avatar-review-details dl>div{display:flex;justify-content:space-between;gap:8px;border-top:1px solid #292e2f;padding-top:4px}.avatar-review-details dt,.avatar-review-details dd{margin:0;font-size:6px}.avatar-review-details dt{color:#697174}.avatar-review-details dd{color:#b9bebe;text-align:right}.avatar-review-details>p{margin:2px 0;color:#8c9495;font-size:7px;line-height:1.4}.avatar-review-details>button{min-height:31px;margin-top:auto}.avatar-review-modal{width:min(680px,calc(100vw - 24px))}.avatar-review-preview{height:min(46vh,390px);display:grid;place-items:center;margin:9px 0;overflow:hidden;border:1px solid var(--shd-border);background:#090b0c}.avatar-review-preview img{width:100%;height:100%;object-fit:contain}.avatar-review-preview span{color:#697174;font-size:7px;letter-spacing:.8px}.avatar-decision-tabs{grid-template-columns:repeat(2,1fr)}@media(max-width:1150px){.avatar-review-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.avatar-review-filter,.avatar-review-grid{grid-template-columns:1fr}.avatar-review-card{grid-template-columns:96px minmax(0,1fr)}.avatar-review-image{min-height:132px}.avatar-review-details>button{min-height:44px}.avatar-decision-tabs{grid-template-columns:1fr}} .assignment-chips>button span,.assignment-results>button>span{display:grid;gap:2px}.assignment-chips>button span small,.assignment-results>button>span small{color:var(--shd-orange);font-size:5px;font-weight:900;letter-spacing:.55px} diff --git a/src/routes/admin/gear/+page.server.ts b/src/routes/admin/gear/+page.server.ts index b4ed215..12459e0 100644 --- a/src/routes/admin/gear/+page.server.ts +++ b/src/routes/admin/gear/+page.server.ts @@ -10,6 +10,12 @@ import { getPendingEntities } from '$lib/server/data-moderation'; const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask']; const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']); +const augmentSlotCount = (slot: string) => slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3; + +async function augmentProfileSnapshot(slot: string) { + const database = await getDatabase(); + return (await database.prepare('SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot')).all(slot); +} export const load: PageServerLoad = async ({ cookies, url }) => { const user = await requireDataView(await getSessionUser(cookies)); @@ -23,12 +29,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => { : filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : ''; const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get(); const pagination = getPagination(url, Number(filtered?.total ?? 0)); - const [gear, brandLinks, talentLinks, brands, talents, pending] = await Promise.all([ + const [gear, brandLinks, talentLinks, brands, talents, augmentRows, stats, pending] = await Promise.all([ (await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset), (await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(), (await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(), (await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), (await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(), + (await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(), + (await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), getPendingEntities(user.id, [ { module: 'brands', action: 'save', kind: 'brand' }, { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] } @@ -44,6 +52,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => { })), brands: [...pendingBrands, ...brands], talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))], + augmentProfiles: slots.map((slot) => ({ + slot, + requiredSlots: augmentSlotCount(slot), + augments: augmentRows.filter((augment) => augment.gear_slot === slot) + })), + stats, summary, pagination, filter @@ -58,6 +72,55 @@ const readRange = (form: FormData, prefix: string) => { }; export const actions: Actions = { + saveAugmentProfile: async ({ request, cookies }) => { + const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); + const form = await request.formData(); + const slot = String(form.get('slot') ?? ''); + if (!slots.includes(slot)) return fail(400, { augmentProfileError: 'Choose a valid gear slot.' }); + const requiredSlots = augmentSlotCount(slot); + const augments = Array.from({ length: requiredSlots }, (_, index) => ({ + slot: index + 1, + statId: String(form.get(`statId${index + 1}`) ?? ''), + rawValue: String(form.get(`value${index + 1}`) ?? '').trim(), + rawUnlockCost: String(form.get(`unlockCost${index + 1}`) ?? '').trim(), + value: Number(form.get(`value${index + 1}`)), + unlockCost: Number(form.get(`unlockCost${index + 1}`)) + })); + if (augments.some((augment) => !augment.statId || !augment.rawValue || !Number.isFinite(augment.value))) { + return fail(400, { augmentProfileError: 'Every tactical augment slot requires exactly one attribute and value.' }); + } + if (augments.some((augment) => !augment.rawUnlockCost || !Number.isInteger(augment.unlockCost) || augment.unlockCost < 0)) { + return fail(400, { augmentProfileError: 'Every tactical augment slot requires a non-negative whole-number unlock cost.' }); + } + const database = await getDatabase(); + const statIds = [...new Set(augments.map((augment) => augment.statId))]; + const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds); + if (Number(validStats?.count) !== statIds.length) return fail(400, { augmentProfileError: 'One or more selected attributes are unavailable.' }); + const before = await augmentProfileSnapshot(slot); + const now = new Date().toISOString(); + await database.exec('BEGIN'); + try { + await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot); + for (const augment of augments) { + await (await database.prepare(`INSERT INTO gear_tactical_augments + (id,gear_slot,augment_slot,stat_attribute_id,value,unlock_cost,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?)`)).run(randomUUID(), slot, augment.slot, augment.statId, augment.value, augment.unlockCost, now, now); + } + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } + await recordIsacAudit({ + actorId: operator.id, + category: 'game_data', + action: before.length ? 'gear_augment_profile.updated' : 'gear_augment_profile.created', + entityId: slot, + before, + after: await augmentProfileSnapshot(slot) + }); + return { augmentProfileSaved: true, resultToken: randomUUID() }; + }, save: async ({ request, cookies }) => { const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const form = await request.formData(); diff --git a/src/routes/admin/gear/+page.svelte b/src/routes/admin/gear/+page.svelte index f0870d4..c7dbc89 100644 --- a/src/routes/admin/gear/+page.svelte +++ b/src/routes/admin/gear/+page.svelte @@ -9,12 +9,35 @@ const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor']; const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, level: 1, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] }); let editing = $state(null); + let augmentEditing = $state(null); let handledResultToken = $state(''); + const stat = (id: string) => data.stats.find((item) => item.id === id); + const format = (value: number | string, statId: string) => { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return '—'; + const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, ''); + const unit = stat(statId)?.unit ?? ''; + return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`; + }; const brandNames = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)?.name ?? 'Archived brand').join(', '); const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; }; const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot); const changeSlot = (slot: string) => { if (editing) editing = { ...editing, slot, talentIds: [] }; }; + const editAugments = (profile: any) => { + augmentEditing = { + ...profile, + augments: Array.from({ length: profile.requiredSlots }, (_, index) => { + const augment = profile.augments.find((item: any) => Number(item.augment_slot) === index + 1); + return augment ? { statId: augment.stat_attribute_id, value: augment.value, unlockCost: augment.unlock_cost } : { statId: '', value: '', unlockCost: '' }; + }) + }; + editing = null; + }; $effect(() => { + if (form?.augmentProfileSaved && form.resultToken && form.resultToken !== handledResultToken) { + handledResultToken = form.resultToken; + augmentEditing = null; + } if (form?.gearSaved && form.resultToken && form.resultToken !== handledResultToken) { handledResultToken = form.resultToken; editing = form.gearMode === 'add' ? blank() : null; @@ -23,7 +46,40 @@ Gear — SHD.build admin -

EQUIPMENT DEFINITION REGISTRY

GEAR

Configure slot, progression, base survivability ranges, and compatible brands.

+

EQUIPMENT DEFINITION REGISTRY

GEAR

Configure shared tactical augments, slot progression, survivability, brands, and talents.

+
+
SHARED TACTICAL AUGMENT PROFILESONE PROFILE PER GEAR TYPE · REUSED BY EVERY MATCHING ITEM
{data.augmentProfiles.filter((profile) => profile.augments.length === profile.requiredSlots).length} / 6 CONFIGURED
+
+ {#each data.augmentProfiles as profile} + + {/each} +
+
+{#if augmentEditing} +
+

SHARED TACTICAL AUGMENTS

{augmentEditing.slot.toUpperCase()} PROFILE

+ + + +

These {augmentEditing.requiredSlots} augment slots apply to every {augmentEditing.slot} item. Each slot grants exactly one attribute and unlocks at its configured tactical-augment cost.

+
+ {#each augmentEditing.augments as augment: any, index} +
+
TACTICAL AUGMENT 0{index + 1}SHARED SLOT
+
ATTRIBUTE
+ + +
+ {/each} +
+ {#if form?.augmentProfileError}

{form.augmentProfileError}

{/if} +
+
+
+{/if} {#if editing}