diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
index efe8ce5..2fdc61f 100644
--- a/docs/DESIGN_SYSTEM.md
+++ b/docs/DESIGN_SYSTEM.md
@@ -347,6 +347,12 @@ 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.
+The exotic talent is owned by exactly one exotic weapon and is authored inline
+inside that weapon editor, never selected from a shared global pool. Its
+engineered sub-panel includes the talent name, shared description template,
+Tier I/II previews, reusable triggers, and paired effect values. The Talent
+Registry may display an owned exotic talent for discovery, but routes editing
+back to its weapon rather than opening the shared-talent editor.
Large relationship sets use the searchable assignment control instead of
checkbox walls. It keeps assigned records visible as removable chips, filters
diff --git a/docs/architecture.md b/docs/architecture.md
index c639498..448c65b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -130,3 +130,17 @@ is directional rather than a monitoring-grade time series. Production alerting
and historical analysis should eventually use a dedicated external telemetry
system. This surface deliberately omits secrets, environment-variable values,
and full database paths.
+
+# Weapon-owned exotic talents
+
+Standard weapon talent pools remain globally reusable and classified by Talent
+1 / Top or Talent 2 / Bottom. An exotic talent is different: one exotic weapon
+owns one dedicated talent, referenced by `weapons.exotic_talent_id`.
+
+The weapon editor creates and updates the underlying normalized `talents`,
+`talent_tiers`, trigger links, effect links, description template, and Exotic
+weapon scope together with the weapon. Server validation rejects an exotic
+talent ID not already owned by the edited weapon and rejects any talent shared
+by another weapon. Converting a weapon back to standard clears the relationship
+and archives its now-unowned exotic talent. Exotic talent creation and editing
+are rejected by the shared Talent Registry so ownership cannot be bypassed.
diff --git a/src/app.css b/src/app.css
index b2a693c..2913ab3 100644
--- a/src/app.css
+++ b/src/app.css
@@ -108,4 +108,5 @@
.audit-toolbar>div:last-child{display:block}.audit-filter{height:auto;display:grid;grid-template-columns:minmax(220px,1fr) 210px 240px auto;gap:7px;margin:10px 0}.audit-filter input,.audit-filter select,.audit-filter button{height:40px;margin:0}.audit-filter button{display:inline-flex;align-items:center;justify-content:center;padding:0 14px;line-height:1}.audit-row{min-width:980px;grid-template-columns:1fr 1fr 1.3fr .85fr .35fr}.audit-entry{border-top:1px solid #272c2d}.audit-entry:first-of-type{border-top:0}.audit-entry summary{border:0;cursor:pointer;list-style:none}.audit-entry summary::-webkit-details-marker{display:none}.audit-entry summary:hover{background:#15191a}.audit-entry summary:active{transform:translateY(1px);filter:brightness(.88)}.audit-entry summary>b{justify-self:end;color:var(--shd-orange);font-size:7px;letter-spacing:.7px}.audit-entry[open] summary{background:#171a1b;box-shadow:inset 2px 0 var(--shd-orange)}.audit-comparison{display:grid;grid-template-columns:1fr 1fr;gap:1px;padding:1px;background:#303536}.audit-comparison section{min-width:0;padding:12px;background:#0b0e0f}.audit-comparison span{color:var(--shd-orange);font-size:7px;font-weight:900;letter-spacing:1px}.audit-comparison pre{max-height:320px;overflow:auto;margin:9px 0 0;color:#aeb4b5;font:10px/1.5 Consolas,monospace;white-space:pre-wrap;word-break:break-word}@media(max-width:900px){.audit-filter{grid-template-columns:1fr 1fr}.audit-comparison{grid-template-columns:1fr}}@media(max-width:560px){.audit-filter{grid-template-columns:1fr}}
.audit-revert-bar{min-height:56px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;border-top:1px solid #4c302d;background:#1c1111}.audit-revert-bar>span{color:#a87870;font-size:7px;letter-spacing:.8px}.audit-revert-button{min-width:174px;height:40px;padding:0 14px}.audit-revert-button>b{font-size:15px;line-height:1}.audit-revert-button:hover,.audit-revert-button:focus-visible{box-shadow:inset 3px 0 var(--shd-danger)}.audit-revert-bar.completed{border-color:#36563f;background:#101713}.audit-revert-bar.completed>span{color:var(--shd-success)}.audit-revert-modal>p{color:#969d9f;font:13px/1.6 Georgia,serif}.audit-revert-modal code{color:#d7d9d5;font:11px Consolas,monospace}.audit-revert-summary{display:grid;grid-template-columns:1fr 1fr 1.4fr;gap:7px;margin:14px 0}.audit-revert-summary>div{min-width:0;display:grid;gap:5px;padding:9px;border:1px solid #303536;background:#0d1011}.audit-revert-summary span{color:#626a6c;font-size:6px;letter-spacing:.8px}.audit-revert-summary b{overflow:hidden;text-overflow:ellipsis;color:#d7d9d5;font-size:8px;white-space:nowrap}@media(max-width:600px){.audit-revert-bar{align-items:stretch;flex-direction:column}.audit-revert-button{width:100%}.audit-revert-summary{grid-template-columns:1fr}}
.metrics-auto{height:40px;display:flex!important;align-items:center;gap:7px;padding:0 10px;border:1px solid var(--shd-border);background:#0d1011;color:#747c7e;font-size:7px;font-weight:900;letter-spacing:.8px;cursor:pointer}.metrics-auto input{width:auto;margin:0;accent-color:var(--shd-orange)}.metrics-auto:has(input:checked){border-color:#6e5226;background:#211a0e;color:var(--shd-orange)}.metrics-refresh{min-width:154px}.metrics-kpis strong{font-size:18px}.metrics-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.metrics-panel{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.metrics-panel>header{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:11px 13px;border-bottom:1px solid var(--shd-border);background:#0d1011}.metrics-panel h3{margin:4px 0 0;font-size:14px;letter-spacing:.5px}.metrics-panel>header>b{color:var(--shd-orange);font-size:7px;letter-spacing:1px}.metric-lines{display:grid}.metric-lines>div{min-height:40px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 12px;border-top:1px solid #252a2b}.metric-lines>div:first-child{border-top:0}.metric-lines span{color:#697174;font-size:7px;letter-spacing:.9px}.metric-lines b{max-width:65%;overflow:hidden;color:#d5d8d5;font-size:9px;text-align:right;text-overflow:ellipsis;white-space:nowrap}.metrics-foot{margin:10px 0 0;padding:9px;border:1px solid #292e2f;color:#596164;font-size:6px;letter-spacing:.8px}@media(max-width:850px){.metrics-grid{grid-template-columns:1fr}}@media(max-width:560px){.metrics-auto,.metrics-refresh{width:100%}.metric-lines b{max-width:58%}}
+.weapon-exotic-talent{margin:10px 0;padding:10px;border:1px solid #765426;border-top:2px solid var(--shd-orange);background:#151208}.exotic-talent-identity{grid-template-columns:minmax(180px,.65fr) minmax(320px,1.35fr)!important}.exotic-talent-identity textarea{min-height:64px}.exotic-talent-identity small{display:block;margin-top:5px;color:#69665e;font-size:7px}.weapon-exotic-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:8px;margin-top:8px}.exotic-effect-list{display:grid;gap:5px}.exotic-effect-row{display:grid;grid-template-columns:28px minmax(150px,1fr) 85px 85px 28px;gap:5px;align-items:center}.exotic-effect-row>strong{height:32px;display:grid;place-items:center;background:#211a0e;color:var(--shd-orange);font-size:8px}.exotic-effect-row select,.exotic-effect-row input{height:32px;min-width:0;margin:0;padding:0 7px;font-size:8px}.exotic-effect-row button{height:32px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.exotic-effect-row button:disabled{opacity:.35;cursor:not-allowed}@media(max-width:1050px){.weapon-exotic-grid{grid-template-columns:1fr}}@media(max-width:700px){.exotic-talent-identity{grid-template-columns:1fr!important}.exotic-effect-row{grid-template-columns:28px 1fr 1fr}.exotic-effect-row select{grid-column:2/4}.exotic-effect-row button{grid-column:3}}
diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts
index 2ad5036..9680fc5 100644
--- a/src/routes/admin/talents/+page.server.ts
+++ b/src/routes/admin/talents/+page.server.ts
@@ -21,7 +21,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: filter === 'body-armor' ? "WHERE category IN ('Vest','Body Armor')" : '';
const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get();
const pagination = getPagination(url, Number(filteredRow?.total ?? 0));
- const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots] = await Promise.all([
+ const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners] = await Promise.all([
(await db.prepare(`SELECT * FROM talents ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
(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(),
@@ -30,7 +30,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(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,scope FROM talent_weapon_scopes')).all(),
- (await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all()
+ (await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all(),
+ (await db.prepare('SELECT id,name,exotic_talent_id FROM weapons WHERE exotic_talent_id IS NOT NULL')).all()
]);
const [triggerSummary, slotSummary, exoticSummary, gearSummary] = await Promise.all([
(await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(),
@@ -58,6 +59,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
? 'Weapon · Slot Unassigned'
: talent.category,
descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '',
+ ownerWeaponId: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.id ?? null,
+ ownerWeaponName: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.name ?? null,
tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
...tier,
triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)),
@@ -131,13 +134,13 @@ export const actions: Actions = {
const triggerIds = [...new Set(form.getAll('triggerIds').map(String))];
let sharedEffects: SharedEffect[];
try { sharedEffects = parseEffects(form.get('effects')); } catch { return fail(400, { talentError: 'Effects could not be read.' }); }
- if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Exotic 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', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' });
if (!descriptionTemplate || !triggerIds.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, trigger, and at least one effect are required.' });
if (sharedEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every effect needs valid Tier I and Tier II values.' });
if (new Set(sharedEffects.map((effect) => effect.statId)).size !== sharedEffects.length) return fail(400, { talentError: 'Each affected attribute can only be selected once.' });
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > sharedEffects.length)) return fail(400, { talentError: 'Description placeholders must reference an existing effect.' });
- const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') || category === 'Exotic Weapon' ? 'Weapon' : category;
+ const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category;
const db = await getDatabase();
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) return fail(409, { talentError: 'That talent already exists in this category.' });
const statRows = await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${sharedEffects.map(() => '?').join(',')})`)).all(...sharedEffects.map((effect) => effect.statId));
@@ -150,14 +153,10 @@ export const actions: Actions = {
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);
- }
+ .run(talentId, 'Standard');
+ 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);
diff --git a/src/routes/admin/talents/+page.svelte b/src/routes/admin/talents/+page.svelte
index d50d500..3c6ed5e 100644
--- a/src/routes/admin/talents/+page.svelte
+++ b/src/routes/admin/talents/+page.svelte
@@ -68,7 +68,7 @@
{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}
{talent.id ? talent.name : 'TALENT DEFINITION'}
-
+
TIER I PREVIEW{preview('I') || 'Description preview'}
TIER II PREVIEW{preview('II') || 'Description preview'}
SHARED ACTIVATION TRIGGERS
@@ -82,4 +82,4 @@
{/if}
-TALENTCATEGORYTIER I EFFECTTIER II EFFECTSTATE
{#each data.talents as item}
{item.name}Tiered talent
{item.category}{tierDescription(item.tiers, 'I')}
{tierDescription(item.tiers, 'II')}
ACTIVE{/each}
+TALENTCATEGORYTIER I EFFECTTIER II EFFECTSTATE
{#each data.talents as item}
{item.name}{item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}
{item.category}{tierDescription(item.tiers, 'I')}
{tierDescription(item.tiers, 'II')}
ACTIVE{#if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}
EDIT WEAPON{:else}
{/if}{:else}
{/if}
{/each}
diff --git a/src/routes/admin/weapons/+page.server.ts b/src/routes/admin/weapons/+page.server.ts
index 7b649fa..766a360 100644
--- a/src/routes/admin/weapons/+page.server.ts
+++ b/src/routes/admin/weapons/+page.server.ts
@@ -10,6 +10,16 @@ import { auditRecord, recordIsacAudit } from '$lib/server/audit';
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage', 'firepower', 'toughness', 'engineering'] as const;
+type ExoticEffect = { statId: string; valueI: number; valueII: number };
+
+const renderTalentDescription = (template: string, effects: ExoticEffect[], units: Map, tier: 'I' | 'II') =>
+ template.replace(/\{(\d+)\}/g, (placeholder, rawIndex) => {
+ const effect = effects[Number(rawIndex) - 1];
+ if (!effect) return placeholder;
+ const value = tier === 'I' ? effect.valueI : effect.valueII;
+ const unit = units.get(effect.statId) === 'flat' ? '' : String(units.get(effect.statId) ?? '');
+ return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
+ });
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.manage');
@@ -19,11 +29,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN is_exotic=0 THEN 1 ELSE 0 END) AS standard,
SUM(CASE WHEN is_exotic=1 THEN 1 ELSE 0 END) AS exotic FROM weapons`)).get();
const filter = ['active', 'standard', 'exotic'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
- const where = filter === 'active' ? 'WHERE active=1' : filter === 'standard' ? 'WHERE is_exotic=0' : filter === 'exotic' ? 'WHERE is_exotic=1' : '';
- const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get();
+ const requestedWeaponId = url.searchParams.get('weapon')?.trim() ?? '';
+ const where = requestedWeaponId ? 'WHERE id=?' : filter === 'active' ? 'WHERE active=1' : filter === 'standard' ? 'WHERE is_exotic=0' : filter === 'exotic' ? 'WHERE is_exotic=1' : '';
+ const whereArgs = requestedWeaponId ? [requestedWeaponId] : [];
+ const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
- const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, exoticTalents, stats] = await Promise.all([
- (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
+ const [weapons, tierRanges, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, triggers, exoticTemplates, exoticTiers, exoticTierTriggers, exoticTierEffects] = await Promise.all([
+ (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY active DESC, category, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
(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(),
@@ -38,28 +50,55 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
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()
+ (await database.prepare("SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE")).all(),
+ (await database.prepare("SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE")).all(),
+ (await database.prepare(`SELECT talent_description_templates.talent_id,talent_description_templates.template,talents.name
+ FROM talent_description_templates JOIN talents ON talents.id=talent_description_templates.talent_id`)).all(),
+ (await database.prepare(`SELECT talent_tiers.id,talent_tiers.talent_id,talent_tiers.tier,talent_tiers.description
+ FROM talent_tiers JOIN talents ON talents.id=talent_tiers.talent_id WHERE talents.category='Weapon'`)).all(),
+ (await database.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
+ (await database.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all()
]);
return {
- weapons: weapons.map((weapon) => ({
+ weapons: weapons.map((weapon) => {
+ const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : '';
+ const talentRows = exoticTiers.filter((tier) => tier.talent_id === talentId);
+ const tier = (name: string) => talentRows.find((item) => item.tier === name);
+ const tierI = tier('I'), tierII = tier('II');
+ const effectIds = [...new Set([
+ ...exoticTierEffects.filter((effect) => effect.talent_tier_id === tierI?.id),
+ ...exoticTierEffects.filter((effect) => effect.talent_tier_id === tierII?.id)
+ ].map((effect) => String(effect.stat_attribute_id)))];
+ return {
...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)
- })),
+ baseBonuses: baseBonuses.filter((effect) => effect.weapon_id === weapon.id),
+ exoticTalent: talentId ? {
+ id: talentId,
+ name: String(exoticTemplates.find((item) => item.talent_id === talentId)?.name ?? ''),
+ descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''),
+ triggerIds: [...new Set([
+ ...exoticTierTriggers.filter((link) => link.talent_tier_id === tierI?.id),
+ ...exoticTierTriggers.filter((link) => link.talent_tier_id === tierII?.id)
+ ].map((link) => String(link.trigger_id)))],
+ effects: effectIds.map((statId) => ({
+ statId,
+ valueI: exoticTierEffects.find((effect) => effect.talent_tier_id === tierI?.id && String(effect.stat_attribute_id) === statId)?.value ?? '',
+ valueII: exoticTierEffects.find((effect) => effect.talent_tier_id === tierII?.id && String(effect.stat_attribute_id) === statId)?.value ?? ''
+ }))
+ } : null
+ }}),
talent1Options,
talent2Options,
- exoticTalents,
stats,
+ triggers,
summary,
pagination,
- filter
+ filter,
+ requestedWeaponId
};
};
@@ -93,7 +132,18 @@ export const actions: Actions = {
};
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;
+ const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
+ const exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : '';
+ const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : '';
+ const exoticTriggerIds = isExotic ? [...new Set(form.getAll('exoticTriggerIds').map(String))] : [];
+ let exoticEffects: ExoticEffect[] = [];
+ try {
+ const parsed = JSON.parse(String(form.get('exoticEffects') ?? '[]')) as Array>;
+ if (!Array.isArray(parsed)) throw new Error();
+ exoticEffects = parsed.map((effect) => ({ statId: String(effect.statId ?? ''), valueI: Number(effect.valueI), valueII: Number(effect.valueII) }));
+ } catch {
+ return fail(400, { weaponError: 'The exotic talent effects could not be read.' });
+ }
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) {
@@ -109,8 +159,16 @@ export const actions: Actions = {
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.' });
+ if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticTriggerIds.length || !exoticEffects.length)) {
+ return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, trigger, and at least one effect.' });
+ }
+ if (isExotic && (exoticEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))
+ || new Set(exoticEffects.map((effect) => effect.statId)).size !== exoticEffects.length)) {
+ return fail(400, { weaponError: 'Every exotic talent effect requires one unique attribute and valid Tier I and Tier II values.' });
+ }
+ if (isExotic) {
+ const placeholders = [...exoticDescriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
+ if (placeholders.some((index) => index < 1 || index > exoticEffects.length)) return fail(400, { weaponError: 'The exotic talent description references a missing effect.' });
}
const tiers = [];
@@ -167,25 +225,67 @@ export const actions: Actions = {
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 statIds = [...new Set([...augmentEffects.map((effect) => effect.statId), ...baseBonuses.map((effect) => effect.statId), ...exoticEffects.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.' });
+ if (isExotic) {
+ const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${exoticTriggerIds.map(() => '?').join(',')})`)).get(...exoticTriggerIds);
+ if (Number(validTriggers?.count) !== exoticTriggerIds.length) return fail(400, { weaponError: 'One or more exotic talent triggers are unavailable.' });
+ if (existingExoticTalentId) {
+ const owned = id ? await (await database.prepare('SELECT id FROM weapons WHERE id=? AND exotic_talent_id=?')).get(id, existingExoticTalentId) : null;
+ if (!owned) return fail(400, { weaponError: 'The submitted exotic talent is not owned by this weapon.' });
+ const shared = await (await database.prepare('SELECT id FROM weapons WHERE exotic_talent_id=? AND id<>?')).get(existingExoticTalentId, id || 'new');
+ if (shared) return fail(409, { weaponError: 'That exotic talent is already owned by another weapon and cannot be shared.' });
+ }
+ if (await (await database.prepare("SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category='Weapon' AND id<>?")).get(exoticTalentName, existingExoticTalentId || 'new')) {
+ return fail(409, { weaponError: 'An exotic talent with that name already exists.' });
+ }
+ }
const weaponId = id || randomUUID();
const now = new Date().toISOString();
+ const exoticTalentId = isExotic ? existingExoticTalentId || randomUUID() : null;
const before = id ? {
record: await auditRecord('weapons', id),
tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id),
talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id),
augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id),
- baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses WHERE weapon_id=? ORDER BY sort_order')).all(id)
+ baseBonuses: await (await database.prepare('SELECT * FROM weapon_base_attribute_bonuses WHERE weapon_id=? ORDER BY sort_order')).all(id),
+ exoticTalent: existingExoticTalentId ? {
+ record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
+ template: await (await database.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(existingExoticTalentId),
+ tiers: await (await database.prepare('SELECT * FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(existingExoticTalentId),
+ triggers: await (await database.prepare(`SELECT talent_tier_triggers.* FROM talent_tier_triggers JOIN talent_tiers ON talent_tiers.id=talent_tier_triggers.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId),
+ effects: await (await database.prepare(`SELECT talent_tier_effects.* FROM talent_tier_effects JOIN talent_tiers ON talent_tiers.id=talent_tier_effects.talent_tier_id WHERE talent_tiers.talent_id=?`)).all(existingExoticTalentId)
+ } : null
} : null;
+ if (isExotic && exoticTalentId) {
+ if (existingExoticTalentId) {
+ await (await database.prepare("UPDATE talents SET name=?,category='Weapon',active=1,updated_at=? WHERE id=?")).run(exoticTalentName, now, exoticTalentId);
+ } else {
+ await (await database.prepare("INSERT INTO talents(id,name,category,active,created_at,updated_at) VALUES(?,?,'Weapon',1,?,?)")).run(exoticTalentId, exoticTalentName, now, now);
+ }
+ await (await database.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,'Exotic')
+ ON CONFLICT(talent_id) DO UPDATE SET scope='Exotic'`)).run(exoticTalentId);
+ await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(exoticTalentId);
+ await (await database.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(exoticTalentId, exoticDescriptionTemplate, now);
+ const unitRows = await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${exoticEffects.map(() => '?').join(',')})`)).all(...exoticEffects.map((effect) => effect.statId));
+ const units = new Map(unitRows.map((row) => [String(row.id), row.unit]));
+ for (const talentTier of ['I', 'II'] as const) {
+ const description = renderTalentDescription(exoticDescriptionTemplate, exoticEffects, units, talentTier);
+ await (await database.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at)
+ VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`))
+ .run(randomUUID(), exoticTalentId, talentTier, description, '', '', now, now);
+ const tierRow = await (await database.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(exoticTalentId, talentTier);
+ const tierId = String(tierRow?.id);
+ await (await database.prepare('DELETE FROM talent_tier_triggers WHERE talent_tier_id=?')).run(tierId);
+ await (await database.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
+ for (const triggerId of exoticTriggerIds) await (await database.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
+ for (const effect of exoticEffects) await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
+ .run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
+ }
+ }
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.' });
@@ -197,6 +297,11 @@ export const actions: Actions = {
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);
}
+ const previousExoticTalentId = typeof before?.record?.exotic_talent_id === 'string' ? before.record.exotic_talent_id : '';
+ if (!isExotic && previousExoticTalentId) {
+ const anotherOwner = await (await database.prepare('SELECT id FROM weapons WHERE exotic_talent_id=? AND id<>?')).get(previousExoticTalentId, weaponId);
+ if (!anotherOwner) await (await database.prepare('UPDATE talents SET active=0,updated_at=? WHERE id=?')).run(now, previousExoticTalentId);
+ }
await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
for (const tier of tiers) {
@@ -217,7 +322,7 @@ export const actions: Actions = {
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);
}
- await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, talent1Ids, talent2Ids, augmentEffects, baseBonuses } });
+ await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, descriptionTemplate: exoticDescriptionTemplate, triggerIds: exoticTriggerIds, effects: exoticEffects } : null } });
return { weaponSaved: true, weaponMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
archive: async ({ request, cookies }) => {
diff --git a/src/routes/admin/weapons/+page.svelte b/src/routes/admin/weapons/+page.svelte
index 1158bc9..92e3bb0 100644
--- a/src/routes/admin/weapons/+page.svelte
+++ b/src/routes/admin/weapons/+page.svelte
@@ -8,12 +8,14 @@
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 }] });
+ const blankExoticTalent = () => ({ id: '', name: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
+ 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: '', exoticTalent: blankExoticTalent(), 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(null);
let handledResultToken = $state('');
+ let openedRequestedWeapon = $state(false);
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 })) };
+ editing = { ...weapon, tiers, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() };
};
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';
@@ -25,8 +27,22 @@
const setExotic = (isExotic: boolean) => {
if (!editing) return;
editing.is_exotic = isExotic ? 1 : 0;
- editing.exotic_talent_id = isExotic ? (editing.exotic_talent_id ?? '') : '';
+ if (isExotic && !editing.exoticTalent) editing.exoticTalent = blankExoticTalent();
};
+ const addExoticEffect = () => {
+ if (editing?.exoticTalent && editing.exoticTalent.effects.length < 12) editing.exoticTalent.effects = [...editing.exoticTalent.effects, { statId: '', valueI: '', valueII: '' }];
+ };
+ const removeExoticEffect = (index: number) => {
+ if (editing?.exoticTalent?.effects.length > 1) editing.exoticTalent.effects = editing.exoticTalent.effects.filter((_: any, effectIndex: number) => effectIndex !== index);
+ };
+ const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+)\}/g, (placeholder: string, rawIndex: string) => {
+ const effect = editing.exoticTalent.effects[Number(rawIndex) - 1];
+ if (!effect) return placeholder;
+ const stat = data.stats.find((item) => item.id === effect.statId);
+ const value = tier === 'I' ? effect.valueI : effect.valueII;
+ const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? '';
+ return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
+ });
const addAugmentEffect = () => {
if (editing && editing.augmentEffects.length < 2) editing.augmentEffects = [...editing.augmentEffects, { stat_attribute_id: '', value: 0, sort_order: 2 }];
};
@@ -40,6 +56,11 @@
if (editing && editing.baseBonuses.length > 1) editing.baseBonuses = editing.baseBonuses.filter((_: any, bonusIndex: number) => bonusIndex !== index);
};
$effect(() => {
+ if (!openedRequestedWeapon && data.requestedWeaponId) {
+ const requested = data.weapons.find((weapon) => weapon.id === data.requestedWeaponId);
+ if (requested) edit(requested);
+ openedRequestedWeapon = true;
+ }
if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.weaponMode === 'add' ? blank() : null;
@@ -62,8 +83,20 @@
-
+ {#if Number(editing.is_exotic)}
+
+
+
+ DEDICATED EXOTIC TALENTOWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED
{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}
+
+ TIER I PREVIEW{exoticPreview('I') || 'Description preview'}
TIER II PREVIEW{exoticPreview('II') || 'Description preview'}
+
+
ACTIVATION TRIGGERSSHARED BETWEEN BOTH TIERS
{editing.exoticTalent.triggerIds.length}
+
ATTRIBUTE EFFECTSONE ROW PER TEMPLATE PLACEHOLDER
+
+
+ {/if}
FIXED WEAPON ATTRIBUTESUNCHANGED ACROSS TIERS
BASE HANDLING
@@ -102,9 +135,8 @@
02+20% DAMAGE
03+40% DAMAGE
04+60% DAMAGE
05+80% DAMAGE
{#if Number(editing.is_exotic)}
06EXOTIC TALENT → TIER II
{/if}
{#if data.talent1Options.length === 0 || data.talent2Options.length === 0}
Create and classify at least one active Talent 1 and Talent 2 weapon talent before adding weapons.
{/if}
{#if data.stats.length === 0}
Create an active attribute before configuring tactical augmentation 01.
{/if}
- {#if Number(editing.is_exotic) && data.exoticTalents.length === 0}
Create an active Exotic Weapon talent before adding an exotic weapon.
{/if}
{#if form?.weaponError}
{form.weaponError}
{/if}
-
+
{/if}