Add ISAC weapon data management
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') ?? '');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user