From e528f18d8f6e13095e6efffa6531b355280f65cb Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 25 Jul 2026 00:49:08 -0400 Subject: [PATCH] Add ISAC weapon data management --- AGENTS.md | 15 ++ database/schema.sql | 70 ++++++ docs/DESIGN_SYSTEM.md | 35 +++ src/app.css | 3 + .../components/SearchableAssignment.svelte | 52 +++++ src/routes/admin/+layout.svelte | 4 +- src/routes/admin/stats/+page.server.ts | 2 +- src/routes/admin/stats/+page.svelte | 7 + src/routes/admin/talents/+page.server.ts | 41 +++- src/routes/admin/talents/+page.svelte | 16 +- src/routes/admin/weapons/+page.server.ts | 208 ++++++++++++++++++ src/routes/admin/weapons/+page.svelte | 115 ++++++++++ 12 files changed, 555 insertions(+), 13 deletions(-) create mode 100644 src/lib/components/SearchableAssignment.svelte create mode 100644 src/routes/admin/weapons/+page.server.ts create mode 100644 src/routes/admin/weapons/+page.svelte diff --git a/AGENTS.md b/AGENTS.md index 3af3882..ba8a15e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,21 @@ or revise a reusable design decision do not require a documentation update. If uncertain whether a visual change is significant, update the design system. +## ISAC editor lifecycle + +All ISAC data-management editors must follow the same successful-save behavior: + +1. Saving an edit to an existing record closes the editor and returns the user + to the registry. +2. Saving a newly added record keeps the editor open with a fresh blank record + so consecutive data entry remains fast. +3. Validation errors keep the editor open and preserve the entered values. +4. Server actions must return the add/edit mode and a unique result token; + client-side reactive handling must consume each token only once to avoid + repeated state updates. + +Apply this convention whenever an ISAC editor is added or materially changed. + ## Database preservation Development data is valuable and may be entered by non-technical contributors. diff --git a/database/schema.sql b/database/schema.sql index 8c7b3e0..f91c5c8 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -165,6 +165,16 @@ CREATE TABLE IF NOT EXISTS talent_description_templates ( template TEXT NOT NULL, updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS talent_weapon_scopes ( + talent_id TEXT PRIMARY KEY REFERENCES talents(id) ON DELETE CASCADE, + scope TEXT NOT NULL CHECK(scope IN ('Standard', 'Exotic')) +); +INSERT OR IGNORE INTO talent_weapon_scopes(talent_id,scope) + SELECT id,'Standard' FROM talents WHERE category='Weapon'; +CREATE TABLE IF NOT EXISTS weapon_talent_slots ( + talent_id TEXT PRIMARY KEY REFERENCES talents(id) ON DELETE CASCADE, + slot INTEGER NOT NULL CHECK(slot IN (1, 2)) +); CREATE TABLE IF NOT EXISTS brand_sets ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -213,3 +223,63 @@ 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 weapons ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL CHECK(category IN ('Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG')), + is_exotic INTEGER NOT NULL DEFAULT 0 CHECK(is_exotic IN (0, 1)), + native_tier INTEGER NOT NULL CHECK(native_tier BETWEEN 0 AND 2), + damage_type TEXT NOT NULL CHECK(damage_type IN ('Blast', 'Pierce', 'Shredding')), + rpm REAL NOT NULL CHECK(rpm >= 0), + magazine REAL NOT NULL CHECK(magazine >= 0), + reload_speed REAL NOT NULL CHECK(reload_speed >= 0), + accuracy REAL NOT NULL CHECK(accuracy >= 0), + stability REAL NOT NULL CHECK(stability >= 0), + exotic_talent_id TEXT REFERENCES talents(id), + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK( + (is_exotic = 1 AND exotic_talent_id IS NOT NULL) + OR + (is_exotic = 0 AND exotic_talent_id IS NULL) + ) +); +CREATE UNIQUE INDEX IF NOT EXISTS weapons_name_nocase_idx ON weapons(name COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS weapon_tier_ranges ( + weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE, + tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2), + damage_min REAL NOT NULL CHECK(damage_min >= 0), + damage_max REAL NOT NULL CHECK(damage_max >= damage_min), + firepower_min REAL NOT NULL CHECK(firepower_min >= 0), + firepower_max REAL NOT NULL CHECK(firepower_max >= firepower_min), + toughness_min REAL NOT NULL CHECK(toughness_min >= 0), + toughness_max REAL NOT NULL CHECK(toughness_max >= toughness_min), + engineering_min REAL NOT NULL CHECK(engineering_min >= 0), + engineering_max REAL NOT NULL CHECK(engineering_max >= engineering_min), + PRIMARY KEY(weapon_id, tier) +); +CREATE TABLE IF NOT EXISTS weapon_talent_options ( + weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE, + talent_slot INTEGER NOT NULL CHECK(talent_slot IN (1, 2)), + talent_id TEXT NOT NULL REFERENCES talents(id), + PRIMARY KEY(weapon_id, talent_slot, talent_id) +); +CREATE TABLE IF NOT EXISTS weapon_augmentation_effects ( + id TEXT PRIMARY KEY, + weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + value REAL NOT NULL CHECK(value > 0), + sort_order INTEGER NOT NULL CHECK(sort_order IN (1, 2)), + UNIQUE(weapon_id, sort_order), + UNIQUE(weapon_id, stat_attribute_id) +); +CREATE TABLE IF NOT EXISTS weapon_base_attribute_bonuses ( + id TEXT PRIMARY KEY, + weapon_id TEXT NOT NULL REFERENCES weapons(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + value REAL NOT NULL CHECK(value > 0), + sort_order INTEGER NOT NULL CHECK(sort_order >= 1), + UNIQUE(weapon_id, stat_attribute_id), + UNIQUE(weapon_id, sort_order) +); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 6c68776..d4068b8 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -204,3 +204,38 @@ buttons for reversible identity changes and muted red controls for destructive or restrictive changes. Every control must expose pointer, hover, focus-visible, pressed, disabled, and inline pending-spinner states; adjacent navigation links use the same height, typography, and interaction motion. + +### Weapon definition editor + +Complex progression definitions use a stacked control-surface editor: identity +and fixed values first, then parallel tier cards, mutually exclusive talent +pools, fixed base bonuses, and the augmentation sequence. Tier cards retain +identical row ordering so values can be compared horizontally on wide screens +and stack without changing meaning on narrow screens. System-derived augment +steps appear as read-only numbered cells; editable effects use the shared +attribute/value row pattern. Exotic is a status layered onto a weapon family, +never a replacement for the family classification. Fixed base bonuses remain +visually separate from tactical augmentation effects. Exotic definitions add a +dedicated exotic-only talent after the two standard talent pools; their sixth +augmentation cell communicates that this talent advances to Tier II. + +Large relationship sets use the searchable assignment control instead of +checkbox walls. It keeps assigned records visible as removable chips, filters +available records by name, renders at most thirty results at once, and exposes +counts for both assignments and matches. Mutually exclusive pools pass their +assigned IDs to one another as exclusions so invalid combinations cannot be +selected. Keyboard focus, pressed state, empty results, and overflow remain +explicit. +Where a relationship has a global slot invariant, filter each assignment +control at the data boundary instead of presenting cross-slot choices. Weapon +talents are classified once as Talent 1 / Top or Talent 2 / Bottom in the +talent registry; weapon editors only curate subsets from the matching slot. + +### ISAC editor lifecycle + +Successful saves have a consistent outcome throughout data management: +editing an existing record closes the editor and returns the user to the registry; +creating a new record keeps the editor open with a fresh blank definition for +rapid consecutive entry. Failed validation leaves the current editor and +entered values intact. Use a unique action result token so reactive handling +runs exactly once and does not create update loops. diff --git a/src/app.css b/src/app.css index 29fed60..e1293d5 100644 --- a/src/app.css +++ b/src/app.css @@ -78,4 +78,7 @@ .section-label{margin-top:10px;border-top:1px solid #2c3132}.user-search{display:flex;gap:6px}.user-search input{height:40px;min-width:240px;margin:0;padding:0 10px;font-size:9px}.user-search button{height:40px}.user-row{min-width:1020px;grid-template-columns:1.1fr .75fr 1.1fr 1.1fr 1fr .3fr}.restricted{color:#df8069!important}.row-action{justify-self:end;color:#f6a821;font-size:8px;font-weight:bold}.button-link{display:grid;place-items:center;height:40px;padding:0 14px;border:1px solid #424849;color:#d7d9d5;font-size:8px;font-weight:bold}.metric-date{font-size:11px!important}.user-console-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:10px}.user-console-grid .dashboard-editor{margin-top:0}.permission-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-panel .field{display:block}.console-note{padding:20px;color:#747c7e;font-size:9px}.user-history-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.user-history-grid>section{min-width:0;border:1px solid #303536;background:#111415}.history-head{display:flex;justify-content:space-between;padding:10px;background:#0d1011;color:#727a7c;font-size:7px;letter-spacing:1px}.history-list{max-height:300px;overflow:auto}.history-list>div{display:grid;grid-template-columns:1fr auto auto;gap:8px;padding:9px 10px;border-top:1px solid #292e2f;font-size:8px}.history-list span{color:#767e80}.history-list b{font-size:7px}.history-list>p{padding:28px;text-align:center;color:#626a6c;font-size:8px}.comment-history>div{grid-template-columns:1fr}.comment-history span{white-space:normal;line-height:1.4} @media(max-width:1000px){.user-console-grid,.user-history-grid{grid-template-columns:1fr}.permission-grid{grid-template-columns:1fr 1fr}}@media(max-width:560px){.user-search{width:100%}.user-search input{min-width:0;flex:1}.permission-grid{grid-template-columns:1fr}} @media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}} +.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:8px}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:55px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;color:#d7d9d5;font-size:9px}.weapon-range-row>strong small{display:block;color:#596164;font-size:5px;letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:6px;letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:8px}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:8px}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:8px}.augment-sequence span{color:#727a7c;font-size:7px}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-dashboard-table{margin-top:10px} +.assignment-picker{border:1px solid #303536;background:#0d1011}.assignment-selected{display:grid;grid-template-columns:70px 1fr;min-height:45px;border-bottom:1px solid #303536}.assignment-summary{display:grid;place-items:center;background:#15191a}.assignment-summary span{color:#687073;font-size:6px;letter-spacing:1px}.assignment-summary b{color:#f6a821;font-size:14px}.assignment-chips{display:flex;align-items:center;gap:5px;overflow:auto;padding:6px}.assignment-chips>button{flex:0 0 auto;display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid #6b5128;background:#211a0e;color:#e2cda7;font-size:7px;cursor:pointer}.assignment-chips>button:hover{border-color:#f6a821;color:#f6a821}.assignment-chips>button:active{transform:translateY(1px)}.assignment-chips small{color:#596164;font-size:7px}.assignment-search{display:block;padding:7px 8px;color:#70787a;font-size:6px;letter-spacing:1px}.assignment-search input{height:32px;margin-top:5px;padding:0 9px;font-size:9px}.assignment-results-head{height:27px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-top:1px solid #262b2c;border-bottom:1px solid #262b2c;color:#687073;font-size:6px;letter-spacing:1px}.assignment-results-head b{color:#8a672b}.assignment-results{max-height:210px;overflow:auto}.assignment-results>button{width:100%;min-height:34px;display:flex;align-items:center;justify-content:space-between;padding:6px 9px;border:0;border-top:1px solid #24292a;background:#111415;color:#a4aaac;text-align:left;font-size:8px;cursor:pointer}.assignment-results>button:first-child{border-top:0}.assignment-results>button:hover,.assignment-results>button:focus-visible{background:#171b1c;color:#fff;box-shadow:inset 2px 0 #f6a821}.assignment-results>button.selected{background:#211a0e;color:#f6a821;box-shadow:inset 2px 0 #f6a821}.assignment-results>button b{font-size:6px;letter-spacing:.7px}.assignment-results>button:active{transform:translateY(1px);filter:brightness(.88)}.assignment-results>p{margin:0;padding:25px;text-align:center;color:#596164;font-size:7px;letter-spacing:1px} +@media(max-width:1200px){.weapon-identity-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-fixed-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.weapon-tier-grid{grid-template-columns:1fr}.weapon-talent-pools{grid-template-columns:1fr}}@media(max-width:700px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr 1fr}.compact-selection{grid-template-columns:1fr}.base-bonus-row,.augment-effect,.augment-effect:not(:has(>button)){grid-template-columns:30px 1fr}.base-bonus-row .field:nth-of-type(2),.augment-effect .field:nth-of-type(2){grid-column:2}.base-bonus-row>button,.augment-effect>button{grid-column:2}.augment-sequence{grid-template-columns:1fr 1fr}}@media(max-width:480px){.weapon-identity-grid,.weapon-fixed-grid{grid-template-columns:1fr}.weapon-range-row{grid-template-columns:45px 1fr 1fr}} diff --git a/src/lib/components/SearchableAssignment.svelte b/src/lib/components/SearchableAssignment.svelte new file mode 100644 index 0000000..19cd0a6 --- /dev/null +++ b/src/lib/components/SearchableAssignment.svelte @@ -0,0 +1,52 @@ + + +
+ {#each selected as id}{/each} +
+
ASSIGNED{selected.length}
+
+ {#each selectedItems as item} + + {:else} + NO TALENTS ASSIGNED + {/each} +
+
+ +
{available.length} MATCHES{#if available.length > visible.length}SHOWING FIRST {visible.length}{/if}
+
+ {#each visible as item} + + {:else} +

{emptyLabel}

+ {/each} +
+
diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 6d75b67..399be38 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -7,7 +7,7 @@ { href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' }, { href: '/admin/brands', label: 'Brand sets', code: 'BRND' }, { href: '/admin/gear', label: 'Gear', code: 'GEAR' }, - { href: '/admin/weapons', label: 'Weapons', code: 'WPN', pending: true } + { href: '/admin/weapons', label: 'Weapons', code: 'WPN' } ]; const userSections = [{ href: '/admin/users', label: 'User operations', code: 'USER' }]; @@ -26,7 +26,7 @@ {#if can('data.manage')} {/if} diff --git a/src/routes/admin/stats/+page.server.ts b/src/routes/admin/stats/+page.server.ts index 52ef69e..363ce1a 100644 --- a/src/routes/admin/stats/+page.server.ts +++ b/src/routes/admin/stats/+page.server.ts @@ -29,7 +29,7 @@ export const actions: Actions = { const now = new Date().toISOString(); if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id); else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(randomUUID(),name,type,category,calculation,unit,...values,now,now); - return { saved: true }; + return { saved: true, editorMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, archive: async ({ request, cookies }) => { await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); diff --git a/src/routes/admin/stats/+page.svelte b/src/routes/admin/stats/+page.svelte index f70b99f..1b74863 100644 --- a/src/routes/admin/stats/+page.svelte +++ b/src/routes/admin/stats/+page.svelte @@ -2,8 +2,15 @@ import PendingForm from '$lib/components/PendingForm.svelte'; let { data, form } = $props(); let editing = $state(null); + let handledResultToken = $state(''); const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''}); const value = (input: unknown) => input ?? ''; + $effect(() => { + if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) { + handledResultToken = form.resultToken; + editing = form.editorMode === 'add' ? blank() : null; + } + }); Stats & attributes — SHD.build admin

ATTRIBUTE REGISTRY

STATS & ATTRIBUTES

Builder values, hidden calculation targets, source ranges, and caps.

diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts index 2d17cf7..f497879 100644 --- a/src/routes/admin/talents/+page.server.ts +++ b/src/routes/admin/talents/+page.server.ts @@ -10,19 +10,31 @@ type SharedEffect = { statId: string; valueI: number; valueII: number }; export const load: PageServerLoad = async ({ cookies }) => { await requirePermission(await getSessionUser(cookies), 'data.manage'); const db = await getDatabase(); - const [talents, tiers, triggers, links, effects, stats, templates] = await Promise.all([ + const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots] = await Promise.all([ (await db.prepare('SELECT * FROM talents ORDER BY active DESC,name COLLATE NOCASE')).all(), (await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(), (await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(), (await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(), (await db.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(), (await db.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(), - (await db.prepare('SELECT talent_id,template FROM talent_description_templates')).all() + (await db.prepare('SELECT talent_id,template FROM talent_description_templates')).all(), + (await db.prepare('SELECT talent_id,scope FROM talent_weapon_scopes')).all(), + (await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all() ]); return { talents: talents.map((talent) => ({ ...talent, - category: talent.category === 'Vest' ? 'Body Armor' : talent.category, + category: talent.category === 'Vest' + ? 'Body Armor' + : talent.category === 'Weapon' && weaponScopes.find((scope) => scope.talent_id === talent.id)?.scope === 'Exotic' + ? 'Exotic Weapon' + : talent.category === 'Weapon' && Number(weaponSlots.find((slot) => slot.talent_id === talent.id)?.slot) === 1 + ? 'Weapon Talent 1' + : talent.category === 'Weapon' && Number(weaponSlots.find((slot) => slot.talent_id === talent.id)?.slot) === 2 + ? 'Weapon Talent 2' + : talent.category === 'Weapon' + ? 'Weapon · Slot Unassigned' + : talent.category, descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '', tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({ ...tier, @@ -74,7 +86,7 @@ export const actions: Actions = { const now = new Date().toISOString(); if (id) await (await db.prepare('UPDATE talent_triggers SET name=?,description=?,supports_uptime=?,min_uptime=?,max_uptime=?,updated_at=? WHERE id=?')).run(name, description, uptime ? 1 : 0, min, max, now, id); else await (await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(randomUUID(), name, description, uptime ? 1 : 0, min, max, now, now); - return { triggerSaved: true }; + return { triggerSaved: true, triggerMode: id ? 'edit' : 'add', resultToken: randomUUID() }; }, saveTalent: async ({ request, cookies }) => { await requirePermission(await getSessionUser(cookies), 'data.manage'); @@ -84,13 +96,13 @@ export const actions: Actions = { const triggerIds = [...new Set(form.getAll('triggerIds').map(String))]; let sharedEffects: SharedEffect[]; try { sharedEffects = parseEffects(form.get('effects')); } catch { return fail(400, { talentError: 'Effects could not be read.' }); } - if (name.length < 2 || !['Weapon', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category.' }); + if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category.' }); if (!descriptionTemplate || !triggerIds.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, trigger, and at least one effect are required.' }); if (sharedEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every effect needs valid Tier I and Tier II values.' }); if (new Set(sharedEffects.map((effect) => effect.statId)).size !== sharedEffects.length) return fail(400, { talentError: 'Each affected attribute can only be selected once.' }); const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1])); if (placeholders.some((index) => index < 1 || index > sharedEffects.length)) return fail(400, { talentError: 'Description placeholders must reference an existing effect.' }); - const storedCategory = category === 'Body Armor' ? 'Vest' : category; + const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') || category === 'Exotic Weapon' ? 'Weapon' : category; const db = await getDatabase(); if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) return fail(409, { talentError: 'That talent already exists in this category.' }); const statRows = await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${sharedEffects.map(() => '?').join(',')})`)).all(...sharedEffects.map((effect) => effect.statId)); @@ -99,6 +111,21 @@ export const actions: Actions = { const talentId = id || randomUUID(), now = new Date().toISOString(); if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name, storedCategory, now, id); else await (await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId, name, storedCategory, now, now); + if (storedCategory === 'Weapon') { + await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?) + ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`)) + .run(talentId, category === 'Exotic Weapon' ? 'Exotic' : 'Standard'); + if (category === 'Exotic Weapon') { + await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId); + } else { + await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?) + ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`)) + .run(talentId, category === 'Weapon Talent 1' ? 1 : 2); + } + } else { + await (await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId); + await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId); + } await (await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?) ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(talentId, descriptionTemplate, now); for (const tier of ['I', 'II'] as const) { @@ -112,6 +139,6 @@ export const actions: Actions = { for (const triggerId of triggerIds) await (await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId); for (const effect of sharedEffects) await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(), tierId, effect.statId, tier === 'I' ? effect.valueI : effect.valueII, now); } - return { talentSaved: true }; + return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() }; } }; diff --git a/src/routes/admin/talents/+page.svelte b/src/routes/admin/talents/+page.svelte index 1695d3d..fc22c58 100644 --- a/src/routes/admin/talents/+page.svelte +++ b/src/routes/admin/talents/+page.svelte @@ -3,7 +3,8 @@ let { data, form } = $props(); type Effect = { statId: string; valueI: string; valueII: string }; let talent = $state(null), trigger = $state(null), uptime = $state(false); - const blankTalent = () => ({ id: '', name: '', category: 'Weapon', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); + let handledResultToken = $state(''); + const blankTalent = () => ({ id: '', name: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] }); const editTalent = (row: any) => { const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] }; const one = tier('I'), two = tier('II'); @@ -37,11 +38,20 @@ const effect = talent.effects[Number(rawIndex) - 1]; return effect ? displayValue(effect, tier) : placeholder; }); + $effect(() => { + if (!form?.resultToken || form.resultToken === handledResultToken) return; + handledResultToken = form.resultToken; + if (form.talentSaved) talent = form.talentMode === 'add' ? blankTalent() : null; + if (form.triggerSaved) { + if (form.triggerMode === 'add') blankTrigger(); + else trigger = null; + } + }); Talents & triggers — SHD.build admin

TALENT REGISTRY

TALENTS & TRIGGERS

Shared behavior with tier-specific values, reusable triggers, and attribute effects.

-
TALENTS{data.talents.length}Registered definitions
TRIGGERS{data.triggers.length}Reusable conditions
WEAPON{data.talents.filter((item) => item.category === 'Weapon').length}Weapon talents
GEAR{data.talents.filter((item) => item.category !== 'Weapon').length}Body armor and backpack
+
TALENTS{data.talents.length}Registered definitions
TOP / TALENT 1{data.talents.filter((item) => item.category === 'Weapon Talent 1').length}First-slot weapon talents
BOTTOM / TALENT 2{data.talents.filter((item) => item.category === 'Weapon Talent 2').length}Second-slot weapon talents
EXOTIC{data.talents.filter((item) => item.category === 'Exotic Weapon').length}Exotic-only talents
{#if trigger}

{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}

{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}

CALCULATION SCRIPTReserved for the future calculation engine

{#if form?.triggerError}

{form.triggerError}

{/if}
@@ -52,7 +62,7 @@

{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}

{talent.id ? talent.name : 'TALENT DEFINITION'}

-
+
TIER I PREVIEW

{preview('I') || 'Description preview'}

TIER II PREVIEW

{preview('II') || 'Description preview'}

SHARED ACTIVATION TRIGGERS

diff --git a/src/routes/admin/weapons/+page.server.ts b/src/routes/admin/weapons/+page.server.ts new file mode 100644 index 0000000..a501699 --- /dev/null +++ b/src/routes/admin/weapons/+page.server.ts @@ -0,0 +1,208 @@ +import { requirePermission } from '$lib/server/auth/authorization'; +import { getSessionUser } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import { fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { Actions, PageServerLoad } from './$types'; + +const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; +const damageTypes = ['Blast', 'Pierce', 'Shredding']; +const rangeNames = ['damage', 'firepower', 'toughness', 'engineering'] as const; + +export const load: PageServerLoad = async ({ cookies }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); + const database = await getDatabase(); + const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, exoticTalents, stats] = await Promise.all([ + (await database.prepare('SELECT * FROM weapons ORDER BY active DESC, category, name COLLATE NOCASE')).all(), + (await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(), + (await database.prepare('SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options')).all(), + (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_augmentation_effects ORDER BY sort_order')).all(), + (await database.prepare('SELECT weapon_id,stat_attribute_id,value,sort_order FROM weapon_base_attribute_bonuses ORDER BY sort_order')).all(), + (await database.prepare(`SELECT talents.id,talents.name FROM talents + JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id + JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id + WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=1 + ORDER BY talents.name COLLATE NOCASE`)).all(), + (await database.prepare(`SELECT talents.id,talents.name FROM talents + JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id + JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id + WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=2 + ORDER BY talents.name COLLATE NOCASE`)).all(), + (await database.prepare(`SELECT talents.id,talents.name FROM talents + JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id + WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Exotic' + ORDER BY talents.name COLLATE NOCASE`)).all(), + (await database.prepare("SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE")).all() + ]); + return { + weapons: weapons.map((weapon) => ({ + ...weapon, + tiers: tierRanges.filter((range) => range.weapon_id === weapon.id), + talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)), + talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)), + augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id), + baseBonuses: baseBonuses.filter((effect) => effect.weapon_id === weapon.id) + })), + talent1Options, + talent2Options, + exoticTalents, + stats + }; +}; + +function number(form: FormData, name: string): number { + return Number(form.get(name)); +} + +function readRange(form: FormData, tier: number, range: (typeof rangeNames)[number]) { + const min = number(form, `tier${tier}${range}Min`); + const max = number(form, `tier${tier}${range}Max`); + if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min) throw new Error(); + return { min, max }; +} + +export const actions: Actions = { + save: async ({ request, cookies }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); + const form = await request.formData(); + const id = String(form.get('id') ?? ''); + const name = String(form.get('name') ?? '').normalize('NFKC').trim(); + const category = String(form.get('category') ?? ''); + const damageType = String(form.get('damageType') ?? ''); + const isExotic = form.get('isExotic') === '1'; + const nativeTier = number(form, 'nativeTier'); + const fixed = { + rpm: number(form, 'rpm'), + magazine: number(form, 'magazine'), + reloadSpeed: number(form, 'reloadSpeed'), + accuracy: number(form, 'accuracy'), + stability: number(form, 'stability') + }; + const talent1Ids = [...new Set(form.getAll('talent1Ids').map(String))]; + const talent2Ids = [...new Set(form.getAll('talent2Ids').map(String))]; + const exoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : null; + + if (name.length < 2 || name.length > 80) return fail(400, { weaponError: 'Weapon names must be 2–80 characters.' }); + if (!categories.includes(category) || !damageTypes.includes(damageType) || !Number.isInteger(nativeTier) || nativeTier < 0 || nativeTier > 2) { + return fail(400, { weaponError: 'Choose a valid category, native tier, and damage type.' }); + } + if (Object.values(fixed).some((value) => !Number.isFinite(value) || value < 0)) { + return fail(400, { weaponError: 'Fixed weapon attributes require non-negative numeric values.' }); + } + if (!Number.isInteger(fixed.magazine) || !Number.isInteger(fixed.rpm)) { + return fail(400, { weaponError: 'RPM and magazine values must be whole numbers.' }); + } + if (!talent1Ids.length || !talent2Ids.length) return fail(400, { weaponError: 'Talent 1 and Talent 2 each require at least one available weapon talent.' }); + if (talent1Ids.some((talentId) => talent2Ids.includes(talentId))) { + return fail(400, { weaponError: 'Talent 1 and Talent 2 availability lists must be unique.' }); + } + if (isExotic && !exoticTalentId) { + return fail(400, { weaponError: 'Exotic weapons require a dedicated exotic talent.' }); + } + + const tiers = []; + try { + for (let tier = 0; tier <= 2; tier++) { + tiers.push({ + tier, + damage: readRange(form, tier, 'damage'), + firepower: readRange(form, tier, 'firepower'), + toughness: readRange(form, tier, 'toughness'), + engineering: readRange(form, tier, 'engineering') + }); + } + } catch { + return fail(400, { weaponError: 'Every tier range requires a non-negative minimum and a maximum greater than or equal to it.' }); + } + + const augmentEffects = []; + for (let order = 1; order <= 2; order++) { + const statId = String(form.get(`augmentStat${order}`) ?? ''); + const rawValue = String(form.get(`augmentValue${order}`) ?? '').trim(); + if (!statId && !rawValue && order === 2) continue; + const value = Number(rawValue); + if (!statId || !Number.isFinite(value) || value <= 0) return fail(400, { weaponError: `Tactical augmentation effect ${order} requires an attribute and a positive value.` }); + augmentEffects.push({ statId, value, order }); + } + if (!augmentEffects.length || new Set(augmentEffects.map((effect) => effect.statId)).size !== augmentEffects.length) { + return fail(400, { weaponError: 'The first tactical augmentation requires one or two unique attribute effects.' }); + } + const baseBonusCount = number(form, 'baseBonusCount'); + if (!Number.isInteger(baseBonusCount) || baseBonusCount < 1 || baseBonusCount > 12) { + return fail(400, { weaponError: 'Weapons require between one and twelve base attribute bonuses.' }); + } + const baseBonuses = []; + for (let order = 1; order <= baseBonusCount; order++) { + const statId = String(form.get(`baseBonusStat${order}`) ?? ''); + const value = number(form, `baseBonusValue${order}`); + if (!statId || !Number.isFinite(value) || value <= 0) return fail(400, { weaponError: `Base attribute bonus ${order} requires an attribute and a positive value.` }); + baseBonuses.push({ statId, value, order }); + } + if (new Set(baseBonuses.map((effect) => effect.statId)).size !== baseBonuses.length) { + return fail(400, { weaponError: 'Each base attribute bonus must reference a different attribute.' }); + } + + const database = await getDatabase(); + if (await (await database.prepare('SELECT id FROM weapons WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) { + return fail(409, { weaponError: 'That weapon name already exists.' }); + } + for (const [slot, ids] of [[1, talent1Ids], [2, talent2Ids]] as const) { + const validTalents = await (await database.prepare(`SELECT COUNT(*) AS count FROM talents + JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id + JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id + WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' + AND weapon_talent_slots.slot=? AND talents.id IN (${ids.map(() => '?').join(',')})`)).get(slot, ...ids); + if (Number(validTalents?.count) !== ids.length) return fail(400, { weaponError: `One or more selected Talent ${slot} options are unavailable or assigned to the wrong slot.` }); + } + if (isExotic) { + const validExoticTalent = await (await database.prepare(`SELECT talents.id FROM talents + JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id + WHERE talents.id=? AND talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Exotic'`)).get(exoticTalentId); + if (!validExoticTalent) return fail(400, { weaponError: 'The selected exotic talent is unavailable.' }); + } + const statIds = [...new Set([...augmentEffects.map((effect) => effect.statId), ...baseBonuses.map((effect) => effect.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, { weaponError: 'One or more tactical augmentation attributes are unavailable.' }); + + const weaponId = id || randomUUID(); + const now = new Date().toISOString(); + if (id) { + const exists = await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id); + if (!exists) return fail(404, { weaponError: 'Weapon not found.' }); + await (await database.prepare(`UPDATE weapons SET name=?,category=?,is_exotic=?,native_tier=?,damage_type=?,rpm=?,magazine=?, + reload_speed=?,accuracy=?,stability=?,exotic_talent_id=?,active=1,updated_at=? WHERE id=?`)) + .run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, id); + } else { + await (await database.prepare(`INSERT INTO weapons(id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed, + accuracy,stability,exotic_talent_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)) + .run(weaponId, name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, now); + } + + await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId); + for (const tier of tiers) { + await (await database.prepare(`INSERT INTO weapon_tier_ranges(weapon_id,tier,damage_min,damage_max,firepower_min, + firepower_max,toughness_min,toughness_max,engineering_min,engineering_max) VALUES(?,?,?,?,?,?,?,?,?,?)`)) + .run(weaponId, tier.tier, tier.damage.min, tier.damage.max, tier.firepower.min, tier.firepower.max, tier.toughness.min, tier.toughness.max, tier.engineering.min, tier.engineering.max); + } + await (await database.prepare('DELETE FROM weapon_talent_options WHERE weapon_id=?')).run(weaponId); + for (const talentId of talent1Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 1, talentId); + for (const talentId of talent2Ids) await (await database.prepare('INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) VALUES(?,?,?)')).run(weaponId, 2, talentId); + await (await database.prepare('DELETE FROM weapon_augmentation_effects WHERE weapon_id=?')).run(weaponId); + for (const effect of augmentEffects) { + await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) + .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order); + } + await (await database.prepare('DELETE FROM weapon_base_attribute_bonuses WHERE weapon_id=?')).run(weaponId); + for (const effect of baseBonuses) { + await (await database.prepare('INSERT INTO weapon_base_attribute_bonuses(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) + .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order); + } + return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() }; + }, + archive: async ({ request, cookies }) => { + await requirePermission(await getSessionUser(cookies), 'data.manage'); + const id = String((await request.formData()).get('id') ?? ''); + await (await (await getDatabase()).prepare('UPDATE weapons SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id); + return { weaponArchived: true }; + } +}; diff --git a/src/routes/admin/weapons/+page.svelte b/src/routes/admin/weapons/+page.svelte new file mode 100644 index 0000000..d2a8cfd --- /dev/null +++ b/src/routes/admin/weapons/+page.svelte @@ -0,0 +1,115 @@ + + +Weapons — SHD.build admin +

ARMAMENT DEFINITION REGISTRY

WEAPONS

Configure fixed handling, tier progression, talent pools, and tactical augmentations.

+
TOTAL WEAPONS{data.weapons.length}All definitions
ACTIVE{data.weapons.filter((weapon) => Number(weapon.active) === 1).length}Available to builder
CATEGORIES{new Set(data.weapons.map((weapon) => weapon.category)).size}/6Types represented
WEAPON TALENTS{data.talent1Options.length + data.talent2Options.length}Slotted options
+ +{#if editing} +
+

{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}

{editing.id ? editing.name : 'WEAPON DEFINITION'}

+ + +
+ + + + + + +
+
FIXED WEAPON ATTRIBUTESUNCHANGED ACROSS TIERS
BASE HANDLING
+
+ + + + + +
+
BASE ATTRIBUTE BONUSESFIXED BONUSES PROVIDED BY THIS WEAPON
+ +
+ {#each editing.baseBonuses as bonus: any, index} +
{String(index + 1).padStart(2, '0')}
+ {/each} +
+
TIER PROGRESSIONDAMAGE AND CORE ATTRIBUTE RANGES
3 TIERS REQUIRED
+
+ {#each [0, 1, 2] as tier} +
TIER {tier} + {#each ranges as range} +
{range[1]}{range[2]}
+ {/each} +
+ {/each} +
+
+
TALENT 1 · TOP POOLGLOBALLY CLASSIFIED FOR FIRST SLOT
{editing.talent1Ids.length}
+
TALENT 2 · BOTTOM POOLGLOBALLY CLASSIFIED FOR SECOND SLOT
{editing.talent2Ids.length}
+
+
TACTICAL AUGMENTATION 01ONE OR TWO UNIQUE ATTRIBUTE UPGRADES
+
+ {#each editing.augmentEffects as effect: any, index} +
0{index + 1}{#if index > 0}{/if}
+ {/each} +
+
02+20% DAMAGE
03+40% DAMAGE
04+60% DAMAGE
05+80% DAMAGE
{#if Number(editing.is_exotic)}
06EXOTIC TALENT → TIER II
{/if}
+ {#if data.talent1Options.length === 0 || data.talent2Options.length === 0}

Create and classify at least one active Talent 1 and Talent 2 weapon talent before adding weapons.

{/if} + {#if data.stats.length === 0}

Create an active attribute before configuring tactical augmentation 01.

{/if} + {#if Number(editing.is_exotic) && data.exoticTalents.length === 0}

Create an active Exotic Weapon talent before adding an exotic weapon.

{/if} + {#if form?.weaponError}

{form.weaponError}

{/if} +
{#if editing.id}{/if}
+
+
+{/if} + +
+
WEAPONCLASSFIXED ATTRIBUTESTIER RANGETALENT POOLSAUGMENTSSTATE
+ {#each data.weapons as weapon} +
{weapon.name}{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES
{weapon.category} · T{weapon.native_tier}{weapon.rpm} RPM · {weapon.magazine} MAG · {weapon.reload_speed} RELOAD{weapon.tiers.length}/3 CONFIGURED{weapon.talent1Ids.length} / {weapon.talent2Ids.length} `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS{Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}
+ {:else}
NO WEAPONS REGISTEREDAdd active weapon talents and attributes, then create the first armament definition.
{/each} +