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
{#each editing.exoticTalent.effects as effect: any, index}
{index + 1}
{/each}
+
+
+ {/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 editing.id}{/if}
+
{#if editing.id}{/if}
{/if}