Add ISAC weapon data management

This commit is contained in:
JB
2026-07-25 00:49:08 -04:00
parent 3f78f0f9d8
commit e528f18d8f
12 changed files with 555 additions and 13 deletions
+15
View File
@@ -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. 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 ## Database preservation
Development data is valuable and may be entered by non-technical contributors. Development data is valuable and may be entered by non-technical contributors.
+70
View File
@@ -165,6 +165,16 @@ CREATE TABLE IF NOT EXISTS talent_description_templates (
template TEXT NOT NULL, template TEXT NOT NULL,
updated_at 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 ( CREATE TABLE IF NOT EXISTS brand_sets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -213,3 +223,63 @@ CREATE TABLE IF NOT EXISTS gear_talents (
talent_id TEXT NOT NULL REFERENCES talents(id), talent_id TEXT NOT NULL REFERENCES talents(id),
PRIMARY KEY(gear_id, talent_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)
);
+35
View File
@@ -204,3 +204,38 @@ buttons for reversible identity changes and muted red controls for destructive
or restrictive changes. Every control must expose pointer, hover, or restrictive changes. Every control must expose pointer, hover,
focus-visible, pressed, disabled, and inline pending-spinner states; adjacent focus-visible, pressed, disabled, and inline pending-spinner states; adjacent
navigation links use the same height, typography, and interaction motion. 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.
+3
View File
@@ -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} .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: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}} @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}}
@@ -0,0 +1,52 @@
<script lang="ts">
type Item = { id: string; name: string };
let {
items,
selected = $bindable<string[]>([]),
excludedIds = [],
fieldName,
emptyLabel = 'NO MATCHING RECORDS'
}: {
items: Item[];
selected?: string[];
excludedIds?: string[];
fieldName: string;
emptyLabel?: string;
} = $props();
let query = $state('');
const normalizedQuery = $derived(query.normalize('NFKC').trim().toLocaleLowerCase());
const available = $derived(items.filter((item) =>
!excludedIds.includes(item.id)
&& (!normalizedQuery || item.name.toLocaleLowerCase().includes(normalizedQuery))
));
const visible = $derived(available.slice(0, 30));
const selectedItems = $derived(selected.map((id) => items.find((item) => item.id === id)).filter((item): item is Item => Boolean(item)));
const toggle = (id: string) => {
selected = selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id];
};
</script>
<div class="assignment-picker">
{#each selected as id}<input type="hidden" name={fieldName} value={id}/>{/each}
<div class="assignment-selected">
<div class="assignment-summary"><span>ASSIGNED</span><b>{selected.length}</b></div>
<div class="assignment-chips">
{#each selectedItems as item}
<button type="button" onclick={() => toggle(item.id)} aria-label={`Remove ${item.name}`}><span>{item.name}</span><b>×</b></button>
{:else}
<small>NO TALENTS ASSIGNED</small>
{/each}
</div>
</div>
<label class="assignment-search"><span>FILTER AVAILABLE TALENTS</span><input type="search" bind:value={query} placeholder="Search by talent name…" autocomplete="off"/></label>
<div class="assignment-results-head"><span>{available.length} MATCHES</span>{#if available.length > visible.length}<b>SHOWING FIRST {visible.length}</b>{/if}</div>
<div class="assignment-results" role="list">
{#each visible as item}
<button type="button" class:selected={selected.includes(item.id)} aria-pressed={selected.includes(item.id)} onclick={() => toggle(item.id)}>
<span>{item.name}</span><b>{selected.includes(item.id) ? 'ASSIGNED' : 'ADD +'}</b>
</button>
{:else}
<p>{emptyLabel}</p>
{/each}
</div>
</div>
+2 -2
View File
@@ -7,7 +7,7 @@
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' }, { href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' },
{ href: '/admin/brands', label: 'Brand sets', code: 'BRND' }, { href: '/admin/brands', label: 'Brand sets', code: 'BRND' },
{ href: '/admin/gear', label: 'Gear', code: 'GEAR' }, { 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' }]; const userSections = [{ href: '/admin/users', label: 'User operations', code: 'USER' }];
</script> </script>
@@ -26,7 +26,7 @@
{#if can('data.manage')} {#if can('data.manage')}
<div class="sidebar-label section-label"><span>DATA MANAGEMENT</span><b>05 MODULES</b></div> <div class="sidebar-label section-label"><span>DATA MANAGEMENT</span><b>05 MODULES</b></div>
<nav aria-label="Data management"> <nav aria-label="Data management">
{#each dataSections as section}<a class:active={page.url.pathname === section.href} class:pending={section.pending} href={section.href}><small>{section.code}</small><span>{section.label}</span>{#if section.pending}<em>SOON</em>{:else}<b>›</b>{/if}</a>{/each} {#each dataSections as section}<a class:active={page.url.pathname === section.href} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>{/each}
</nav> </nav>
{/if} {/if}
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div> <div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
+1 -1
View File
@@ -29,7 +29,7 @@ export const actions: Actions = {
const now = new Date().toISOString(); 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); 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); 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 }) => { archive: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? ''); await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? '');
+7
View File
@@ -2,8 +2,15 @@
import PendingForm from '$lib/components/PendingForm.svelte'; import PendingForm from '$lib/components/PendingForm.svelte';
let { data, form } = $props(); let { data, form } = $props();
let editing = $state<any>(null); let editing = $state<any>(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 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 ?? ''; const value = (input: unknown) => input ?? '';
$effect(() => {
if (form?.saved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.editorMode === 'add' ? blank() : null;
}
});
</script> </script>
<svelte:head><title>Stats & attributes — SHD.build admin</title></svelte:head> <svelte:head><title>Stats & attributes — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>STATS & ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div> <div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>STATS & ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div>
+34 -7
View File
@@ -10,19 +10,31 @@ type SharedEffect = { statId: string; valueI: number; valueII: number };
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage'); await requirePermission(await getSessionUser(cookies), 'data.manage');
const db = await getDatabase(); 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 * 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 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 * 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,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 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 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 { return {
talents: talents.map((talent) => ({ talents: talents.map((talent) => ({
...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 ?? '', descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '',
tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({ tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
...tier, ...tier,
@@ -74,7 +86,7 @@ export const actions: Actions = {
const now = new Date().toISOString(); 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); 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); 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 }) => { saveTalent: async ({ request, cookies }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage'); await requirePermission(await getSessionUser(cookies), 'data.manage');
@@ -84,13 +96,13 @@ export const actions: Actions = {
const triggerIds = [...new Set(form.getAll('triggerIds').map(String))]; const triggerIds = [...new Set(form.getAll('triggerIds').map(String))];
let sharedEffects: SharedEffect[]; let sharedEffects: SharedEffect[];
try { sharedEffects = parseEffects(form.get('effects')); } catch { return fail(400, { talentError: 'Effects could not be read.' }); } 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 (!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 (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.' }); 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])); 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.' }); 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(); 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.' }); 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)); 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(); 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); 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); 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(?,?,?) 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); 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) { 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 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); 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() };
} }
}; };
+13 -3
View File
@@ -3,7 +3,8 @@
let { data, form } = $props(); let { data, form } = $props();
type Effect = { statId: string; valueI: string; valueII: string }; type Effect = { statId: string; valueI: string; valueII: string };
let talent = $state<any>(null), trigger = $state<any>(null), uptime = $state(false); let talent = $state<any>(null), trigger = $state<any>(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 editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] }; const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', triggerIds: [], effects: [] };
const one = tier('I'), two = tier('II'); const one = tier('I'), two = tier('II');
@@ -37,11 +38,20 @@
const effect = talent.effects[Number(rawIndex) - 1]; const effect = talent.effects[Number(rawIndex) - 1];
return effect ? displayValue(effect, tier) : placeholder; 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;
}
});
</script> </script>
<svelte:head><title>Talents & triggers — SHD.build admin</title></svelte:head> <svelte:head><title>Talents & triggers — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS & TRIGGERS</h2><p>Shared behavior with tier-specific values, reusable triggers, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div> <div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS & TRIGGERS</h2><p>Shared behavior with tier-specific values, reusable triggers, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={() => talent = blankTalent()}>ADD TALENT +</button></div></div>
<div class="module-kpis"><div><span>TALENTS</span><strong>{data.talents.length}</strong><small>Registered definitions</small></div><div><span>TRIGGERS</span><strong>{data.triggers.length}</strong><small>Reusable conditions</small></div><div><span>WEAPON</span><strong>{data.talents.filter((item) => item.category === 'Weapon').length}</strong><small>Weapon talents</small></div><div><span>GEAR</span><strong>{data.talents.filter((item) => item.category !== 'Weapon').length}</strong><small>Body armor and backpack</small></div></div> <div class="module-kpis"><div><span>TALENTS</span><strong>{data.talents.length}</strong><small>Registered definitions</small></div><div><span>TOP / TALENT 1</span><strong>{data.talents.filter((item) => item.category === 'Weapon Talent 1').length}</strong><small>First-slot weapon talents</small></div><div><span>BOTTOM / TALENT 2</span><strong>{data.talents.filter((item) => item.category === 'Weapon Talent 2').length}</strong><small>Second-slot weapon talents</small></div><div><span>EXOTIC</span><strong>{data.talents.filter((item) => item.category === 'Exotic Weapon').length}</strong><small>Exotic-only talents</small></div></div>
{#if trigger} {#if trigger}
<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}</p><h3>{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}</h3></div><button aria-label="Close trigger editor" onclick={() => trigger = null}>×</button></div><PendingForm action="?/saveTrigger"><input type="hidden" name="id" value={trigger.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={trigger.name} required/></label><label class="field">DESCRIPTION<textarea name="description" bind:value={trigger.description} required></textarea></label><label class="field uptime-field"><span><input name="supportsUptime" type="checkbox" bind:checked={uptime}/> ENABLE THEORETICAL UPTIME</span><div><input name="minUptime" type="number" min="0" max="100" bind:value={trigger.min_uptime} disabled={!uptime} placeholder="MIN %"/><input name="maxUptime" type="number" min="0" max="100" bind:value={trigger.max_uptime} disabled={!uptime} placeholder="MAX %"/></div></label><p class="script-note"><b>CALCULATION SCRIPT</b><span>Reserved for the future calculation engine</span></p></div>{#if form?.triggerError}<p class="error">{form.triggerError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => trigger = null}>CANCEL</button><button class="primary">SAVE TRIGGER →</button></div></PendingForm></div> <div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{trigger.id ? 'MODIFY TRIGGER' : 'NEW TRIGGER'}</p><h3>{trigger.id ? trigger.name : 'TRIGGER DEFINITION'}</h3></div><button aria-label="Close trigger editor" onclick={() => trigger = null}>×</button></div><PendingForm action="?/saveTrigger"><input type="hidden" name="id" value={trigger.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={trigger.name} required/></label><label class="field">DESCRIPTION<textarea name="description" bind:value={trigger.description} required></textarea></label><label class="field uptime-field"><span><input name="supportsUptime" type="checkbox" bind:checked={uptime}/> ENABLE THEORETICAL UPTIME</span><div><input name="minUptime" type="number" min="0" max="100" bind:value={trigger.min_uptime} disabled={!uptime} placeholder="MIN %"/><input name="maxUptime" type="number" min="0" max="100" bind:value={trigger.max_uptime} disabled={!uptime} placeholder="MAX %"/></div></label><p class="script-note"><b>CALCULATION SCRIPT</b><span>Reserved for the future calculation engine</span></p></div>{#if form?.triggerError}<p class="error">{form.triggerError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => trigger = null}>CANCEL</button><button class="primary">SAVE TRIGGER →</button></div></PendingForm></div>
@@ -52,7 +62,7 @@
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div> <div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
<PendingForm action="?/saveTalent"> <PendingForm action="?/saveTalent">
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/> <input type="hidden" name="id" value={talent.id}/><input type="hidden" name="effects" value={JSON.stringify(talent.effects)}/>
<div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY<select name="category" bind:value={talent.category}><option>Weapon</option><option>Backpack</option><option>Body Armor</option></select></label></div> <div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY / SLOT<select name="category" bind:value={talent.category}><option value="Weapon · Slot Unassigned" disabled>Weapon · Choose a slot</option><option>Weapon Talent 1</option><option>Weapon Talent 2</option><option>Exotic Weapon</option><option>Backpack</option><option>Body Armor</option></select></label></div>
<label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder="Hitting an enemy {1} times grants +{2}..." required></textarea><small>Use {'{1}'}, {'{2}'}, and so on to insert each effect value with its unit.</small></label> <label class="field template-field">SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder="Hitting an enemy {1} times grants +{2}..." required></textarea><small>Use {'{1}'}, {'{2}'}, and so on to insert each effect value with its unit.</small></label>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div> <div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
<p class="label">SHARED ACTIVATION TRIGGERS</p> <p class="label">SHARED ACTIVATION TRIGGERS</p>
+208
View File
@@ -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 };
}
};
+115
View File
@@ -0,0 +1,115 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
let { data, form } = $props();
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const;
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, firepower_min: 0, firepower_max: 0, toughness_min: 0, toughness_max: 0, engineering_min: 0, engineering_max: 0 });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: 0, damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null);
let handledResultToken = $state('');
const edit = (weapon: any) => {
const tiers = [0, 1, 2].map((tier) => ({ ...blankTier(tier), ...(weapon.tiers.find((item: any) => Number(item.tier) === tier) ?? {}) }));
editing = { ...weapon, tiers, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })) };
};
const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier);
const statLabel = (id: string) => data.stats.find((stat) => stat.id === id)?.name ?? 'Unknown attribute';
const talentLabel = (id: string) => [...data.talent1Options, ...data.talent2Options].find((talent) => talent.id === id)?.name ?? 'Unknown talent';
const setCategory = (category: string) => {
if (!editing) return;
editing.category = category;
};
const setExotic = (isExotic: boolean) => {
if (!editing) return;
editing.is_exotic = isExotic ? 1 : 0;
editing.exotic_talent_id = isExotic ? (editing.exotic_talent_id ?? '') : '';
};
const addAugmentEffect = () => {
if (editing && editing.augmentEffects.length < 2) editing.augmentEffects = [...editing.augmentEffects, { stat_attribute_id: '', value: 0, sort_order: 2 }];
};
const removeAugmentEffect = (index: number) => {
if (editing && index > 0) editing.augmentEffects = editing.augmentEffects.filter((_: any, effectIndex: number) => effectIndex !== index);
};
const addBaseBonus = () => {
if (editing && editing.baseBonuses.length < 12) editing.baseBonuses = [...editing.baseBonuses, { stat_attribute_id: '', value: 0, sort_order: editing.baseBonuses.length + 1 }];
};
const removeBaseBonus = (index: number) => {
if (editing && editing.baseBonuses.length > 1) editing.baseBonuses = editing.baseBonuses.filter((_: any, bonusIndex: number) => bonusIndex !== index);
};
$effect(() => {
if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.weaponMode === 'add' ? blank() : null;
}
});
</script>
<svelte:head><title>Weapons — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT DEFINITION REGISTRY</p><h2>WEAPONS</h2><p>Configure fixed handling, tier progression, talent pools, and tactical augmentations.</p></div><button class="primary" onclick={() => editing = blank()}>ADD WEAPON <span>+</span></button></div>
<div class="module-kpis"><div><span>TOTAL WEAPONS</span><strong>{data.weapons.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.weapons.filter((weapon) => Number(weapon.active) === 1).length}</strong><small>Available to builder</small></div><div><span>CATEGORIES</span><strong>{new Set(data.weapons.map((weapon) => weapon.category)).size}/6</strong><small>Types represented</small></div><div><span>WEAPON TALENTS</span><strong>{data.talent1Options.length + data.talent2Options.length}</strong><small>Slotted options</small></div></div>
{#if editing}
<div class="dashboard-editor weapon-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}</p><h3>{editing.id ? editing.name : 'WEAPON DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<input type="hidden" name="id" value={editing.id}/>
<div class="weapon-identity-grid">
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
<label class="field">CATEGORY<select name="category" value={editing.category} onchange={(event) => setCategory(event.currentTarget.value)}>{#each categories as category}<option>{category}</option>{/each}</select></label>
<label class="field">NATIVE TIER<select name="nativeTier" bind:value={editing.native_tier}><option value={0}>Tier 0</option><option value={1}>Tier 1</option><option value={2}>Tier 2</option></select></label>
<label class="field">DAMAGE TYPE<select name="damageType" bind:value={editing.damage_type}>{#each damageTypes as damageType}<option>{damageType}</option>{/each}</select></label>
<label class="field exotic-toggle">EXOTIC STATUS<span><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/> EXOTIC WEAPON</span></label>
<label class="field">EXOTIC TALENT<select name="exoticTalentId" bind:value={editing.exotic_talent_id} disabled={!Number(editing.is_exotic)} required={Boolean(Number(editing.is_exotic))}><option value="" disabled>SELECT EXOTIC TALENT</option>{#each data.exoticTalents as talent}<option value={talent.id}>{talent.name}</option>{/each}</select></label>
</div>
<div class="selection-head"><div><span>FIXED WEAPON ATTRIBUTES</span><small>UNCHANGED ACROSS TIERS</small></div><b>BASE HANDLING</b></div>
<div class="weapon-fixed-grid">
<label class="field">RPM<input name="rpm" type="number" min="0" step="1" bind:value={editing.rpm} required/></label>
<label class="field">MAGAZINE<input name="magazine" type="number" min="0" step="1" bind:value={editing.magazine} required/></label>
<label class="field">RELOAD SPEED<input name="reloadSpeed" type="number" min="0" step="any" bind:value={editing.reload_speed} required/></label>
<label class="field">ACCURACY<input name="accuracy" type="number" min="0" step="any" bind:value={editing.accuracy} required/></label>
<label class="field">STABILITY<input name="stability" type="number" min="0" step="any" bind:value={editing.stability} required/></label>
</div>
<div class="selection-head"><div><span>BASE ATTRIBUTE BONUSES</span><small>FIXED BONUSES PROVIDED BY THIS WEAPON</small></div><button type="button" class="row-action" onclick={addBaseBonus} disabled={editing.baseBonuses.length >= 12}>ADD BONUS +</button></div>
<input type="hidden" name="baseBonusCount" value={editing.baseBonuses.length}/>
<div class="weapon-base-bonuses">
{#each editing.baseBonuses as bonus: any, index}
<div class="base-bonus-row"><strong>{String(index + 1).padStart(2, '0')}</strong><label class="field">ATTRIBUTE<select name={`baseBonusStat${index + 1}`} bind:value={bonus.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit || 'no unit'})</option>{/each}</select></label><label class="field">VALUE<input name={`baseBonusValue${index + 1}`} type="number" step="any" bind:value={bonus.value} required/></label><button type="button" aria-label={`Remove base bonus ${index + 1}`} onclick={() => removeBaseBonus(index)} disabled={editing.baseBonuses.length === 1}>×</button></div>
{/each}
</div>
<div class="selection-head"><div><span>TIER PROGRESSION</span><small>DAMAGE AND CORE ATTRIBUTE RANGES</small></div><b>3 TIERS REQUIRED</b></div>
<div class="weapon-tier-grid">
{#each [0, 1, 2] as tier}
<fieldset class="weapon-tier-card"><legend>TIER {tier}</legend>
{#each ranges as range}
<div class="weapon-range-row"><strong>{range[1]}<small>{range[2]}</small></strong><label>MIN<input name={`tier${tier}${range[0]}Min`} type="number" min="0" step="any" value={tierAt(tier)[`${range[0]}_min`]} oninput={(event) => tierAt(tier)[`${range[0]}_min`] = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}${range[0]}Max`} type="number" min="0" step="any" value={tierAt(tier)[`${range[0]}_max`]} oninput={(event) => tierAt(tier)[`${range[0]}_max`] = event.currentTarget.valueAsNumber} required/></label></div>
{/each}
</fieldset>
{/each}
</div>
<div class="weapon-talent-pools">
<div><div class="selection-head"><div><span>TALENT 1 · TOP POOL</span><small>GLOBALLY CLASSIFIED FOR FIRST SLOT</small></div><b>{editing.talent1Ids.length}</b></div><SearchableAssignment items={data.talent1Options} fieldName="talent1Ids" bind:selected={editing.talent1Ids}/></div>
<div><div class="selection-head"><div><span>TALENT 2 · BOTTOM POOL</span><small>GLOBALLY CLASSIFIED FOR SECOND SLOT</small></div><b>{editing.talent2Ids.length}</b></div><SearchableAssignment items={data.talent2Options} fieldName="talent2Ids" bind:selected={editing.talent2Ids}/></div>
</div>
<div class="selection-head"><div><span>TACTICAL AUGMENTATION 01</span><small>ONE OR TWO UNIQUE ATTRIBUTE UPGRADES</small></div><button type="button" class="row-action" onclick={addAugmentEffect} disabled={editing.augmentEffects.length >= 2}>ADD EFFECT +</button></div>
<div class="weapon-augment-effects">
{#each editing.augmentEffects as effect: any, index}
<div class="augment-effect"><strong>0{index + 1}</strong><label class="field">ATTRIBUTE<select name={`augmentStat${index + 1}`} bind:value={effect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as stat}<option value={stat.id}>{stat.name} ({stat.unit || 'no unit'})</option>{/each}</select></label><label class="field">VALUE<input name={`augmentValue${index + 1}`} type="number" min="0" step="any" bind:value={effect.value} required/></label>{#if index > 0}<button type="button" aria-label="Remove second augmentation effect" onclick={() => removeAugmentEffect(index)}>×</button>{/if}</div>
{/each}
</div>
<div class="augment-sequence"><div><b>02</b><span>+20% DAMAGE</span></div><div><b>03</b><span>+40% DAMAGE</span></div><div><b>04</b><span>+60% DAMAGE</span></div><div><b>05</b><span>+80% DAMAGE</span></div>{#if Number(editing.is_exotic)}<div class="exotic-step"><b>06</b><span>EXOTIC TALENT → TIER II</span></div>{/if}</div>
{#if data.talent1Options.length === 0 || data.talent2Options.length === 0}<p class="error">Create and classify at least one active Talent 1 and Talent 2 weapon talent before adding weapons.</p>{/if}
{#if data.stats.length === 0}<p class="error">Create an active attribute before configuring tactical augmentation 01.</p>{/if}
{#if Number(editing.is_exotic) && data.exoticTalents.length === 0}<p class="error">Create an active Exotic Weapon talent before adding an exotic weapon.</p>{/if}
{#if form?.weaponError}<p class="error">{form.weaponError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE WEAPON</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.talent1Options.length === 0 || data.talent2Options.length === 0 || data.stats.length === 0 || (Number(editing.is_exotic) === 1 && data.exoticTalents.length === 0)}>SAVE WEAPON →</button></div></div>
</PendingForm>
</div>
{/if}
<div class="dashboard-table weapon-dashboard-table">
<div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div>
{#each data.weapons as weapon}
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1}><div><strong>{weapon.name}</strong><small>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES</small></div><span>{weapon.category} · T{weapon.native_tier}</span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/3 CONFIGURED</span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class="state-dot">{Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(weapon)}>EDIT</button></div>
{:else}<div class="empty-dashboard"><strong>NO WEAPONS REGISTERED</strong><span>Add active weapon talents and attributes, then create the first armament definition.</span></div>{/each}
</div>