feat: expand profiles and weapon configuration
Validate and deploy production / validate (push) Successful in 26s
Validate and deploy staging / validate (push) Successful in 25s
Validate and deploy production / deploy (push) Failing after 1m10s
Validate and deploy staging / deploy (push) Failing after 8s

This commit is contained in:
JB
2026-07-28 00:11:40 -04:00
parent 0b3be3587b
commit a434cbe60c
37 changed files with 2209 additions and 800 deletions
+64 -10
View File
@@ -10,6 +10,8 @@ import { error, fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { agentProfileHref } from '$lib/agents';
import { profileCommentNotificationRecipient } from '$lib/server/profile-comment-thread';
import { notifyProfileCommentFollowers } from '$lib/server/profile-comment-follows';
const COMMENT_PERMISSION = 'community.comments.moderate';
const PROFILE_PERMISSION = 'community.profiles.moderate';
@@ -95,6 +97,12 @@ const queueTargetHref = (
? `/builds/${encodeURIComponent(String(slug))}`
: `/builds/${encodeURIComponent(String(contentId))}`;
}
if (String(targetType) === 'profile') {
return agentProfileHref({
id: String(targetId),
publicSlug: typeof publicSlug === 'string' ? publicSlug : null
}, `profile-comment-${String(contentId)}`);
}
return targetHref(String(targetType), targetId, slug, publicSlug);
};
@@ -123,7 +131,10 @@ const assertQueuePermission = async (
const scanQueueRecord = async (database: Awaited<ReturnType<typeof getDatabase>>, id: string) =>
(await database.prepare(`SELECT scan.*,revision.body_markdown AS revision_body,
comments.body_markdown AS comment_body,comments.status AS comment_status,
comments.thread_id,thread.target_type,thread.target_id,target_build.slug,target_build.title AS target_title,
comments.thread_id,comments.parent_id,parent_comment.author_id AS parent_author_id,
thread.target_type,thread.target_id,target_build.slug,
COALESCE(target_build.title,target_profile.display_name,target_user.username) AS target_title,
target_profile.public_slug AS target_public_slug,
target_build.guide_body AS current_guide_body,target_build.visibility AS current_guide_visibility,
target_build.owner_id AS guide_owner_id,
profile.bio_markdown AS current_bio,profile.bio_updated_at AS current_bio_updated_at,
@@ -133,10 +144,15 @@ const scanQueueRecord = async (database: Awaited<ReturnType<typeof getDatabase>>
LEFT JOIN moderated_content_revisions revision ON revision.id=scan.revision_id
LEFT JOIN comments ON scan.content_type='comment' AND comments.id=scan.content_id
LEFT JOIN comment_threads thread ON thread.id=comments.thread_id
LEFT JOIN comments parent_comment ON parent_comment.id=comments.parent_id
AND parent_comment.thread_id=comments.thread_id
LEFT JOIN builds target_build ON
(scan.content_type='guide' AND target_build.id=scan.content_id)
OR (scan.content_type='comment' AND thread.target_type IN ('build','guide')
AND target_build.id=thread.target_id)
LEFT JOIN user_profiles target_profile ON scan.content_type='comment'
AND thread.target_type='profile' AND target_profile.user_id=thread.target_id
LEFT JOIN users target_user ON target_user.id=target_profile.user_id
LEFT JOIN user_profiles profile ON profile.user_id=scan.author_id
LEFT JOIN users ON users.id=scan.author_id
WHERE scan.id=? AND scan.content_type IN ('comment','profile_bio','guide')
@@ -634,7 +650,7 @@ export const actions: Actions = {
if (!body.trim()) return queueActionFailure(409, 'The queued content is no longer available.');
const now = new Date().toISOString();
const reviewId = randomUUID();
let targetOwnerId: string | null = null;
let commentRecipientId: string | null = null;
await database.exec('BEGIN IMMEDIATE');
try {
const claim = await (await database.prepare(`INSERT INTO content_moderation_reviews
@@ -651,9 +667,16 @@ export const actions: Actions = {
SET status='visible',removed_at=NULL,removed_by=NULL,removal_reason=NULL,updated_at=?
WHERE id=? AND status='pending_review'`)).run(now, scan.content_id);
if (!result.changes) throw new Error('The pending comment changed before approval.');
targetOwnerId = String(scan.target_type) === 'profile'
const targetOwnerId = String(scan.target_type) === 'profile'
? String(scan.target_id)
: scan.guide_owner_id ? String(scan.guide_owner_id) : null;
: scan.guide_owner_id ? String(scan.guide_owner_id) : '';
commentRecipientId = targetOwnerId
? profileCommentNotificationRecipient(
String(scan.author_id),
scan.parent_author_id ? String(scan.parent_author_id) : null,
targetOwnerId
)
: null;
} else if (contentType === 'profile_bio') {
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
@@ -685,6 +708,7 @@ export const actions: Actions = {
policyVersion: Number(scan.policy_version),
provider: String(scan.provider),
model: String(scan.model),
parentId: scan.parent_id ? String(scan.parent_id) : null,
currentStatus: contentType === 'comment'
? String(scan.comment_status)
: contentType === 'profile_bio' ? 'revision_pending' : 'revision_pending'
@@ -714,7 +738,8 @@ export const actions: Actions = {
scan.content_id,
scan.target_type,
scan.target_id,
scan.slug
scan.slug,
scan.target_public_slug
);
await createNotification({
userId: authorId,
@@ -728,15 +753,41 @@ export const actions: Actions = {
actions: [{ label: 'VIEW CONTENT', href: link }],
mandatory: true
});
if (contentType === 'comment' && targetOwnerId && targetOwnerId !== authorId) {
if (contentType === 'comment' && commentRecipientId && commentRecipientId !== authorId) {
const isProfileReply = String(scan.target_type) === 'profile' && Boolean(scan.parent_id);
const isDirectReplyRecipient = Boolean(
scan.parent_author_id
&& commentRecipientId === String(scan.parent_author_id)
);
await createNotification({
userId: targetOwnerId,
userId: commentRecipientId,
category: 'comments',
source: 'agent',
title: 'New comment',
body: `${String(scan.display_name ?? 'An Agent')} posted a comment on your ${String(scan.target_type) === 'profile' ? 'Agent profile' : 'build'}.`,
title: isProfileReply ? 'New profile reply' : 'New comment',
body: isProfileReply
? isDirectReplyRecipient
? `${String(scan.display_name ?? 'An Agent')} replied to your message on ${String(scan.target_title ?? 'an Agent')}'s Agent profile.`
: `${String(scan.display_name ?? 'An Agent')} replied in a conversation on your Agent profile.`
: `${String(scan.display_name ?? 'An Agent')} posted a comment on your ${String(scan.target_type) === 'profile' ? 'Agent profile' : 'build'}.`,
link,
actions: [{ label: 'VIEW COMMENT', href: link }]
actions: [{ label: isProfileReply ? 'VIEW REPLY' : 'VIEW COMMENT', href: link }]
});
}
if (
contentType === 'comment'
&& String(scan.target_type) === 'profile'
&& Boolean(scan.parent_id)
) {
await notifyProfileCommentFollowers({
commentId: String(scan.content_id),
authorId,
authorDisplayName: String(scan.display_name ?? 'An Agent'),
profileOwnerId: String(scan.target_id),
profileOwnerDisplayName: String(scan.target_title ?? 'an Agent'),
profileOwnerPublicSlug: scan.target_public_slug
? String(scan.target_public_slug)
: null,
directRecipientId: commentRecipientId
});
}
return { communitySaved: true, communityResultToken: randomUUID() };
@@ -947,6 +998,7 @@ export const actions: Actions = {
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
parentId: comment.parent_id ? String(comment.parent_id) : null,
targetType: String(comment.target_type),
targetId: String(comment.target_id),
updatedAt: String(comment.updated_at)
@@ -1005,6 +1057,7 @@ export const actions: Actions = {
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
parentId: comment.parent_id ? String(comment.parent_id) : null,
removedAt: String(comment.removed_at),
removedBy: String(comment.removed_by),
removalReason: String(comment.removal_reason),
@@ -1026,6 +1079,7 @@ export const actions: Actions = {
bodyMarkdown: String(comment.body_markdown),
authorId: String(comment.author_id),
threadId: String(comment.thread_id),
parentId: comment.parent_id ? String(comment.parent_id) : null,
restorationReason: reason,
updatedAt: now
}
+16 -3
View File
@@ -76,7 +76,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons] = await Promise.all([
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, talentIcons] = await Promise.all([
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
@@ -85,11 +85,24 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
]);
const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand');
const pendingTalents = pendingDefinitions.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category)));
const pendingStats = pendingDefinitions.filter((item) => item.kind === 'attribute');
const talentOptionWithIcon = (talent: Record<string, any>) => {
const talentId = String(talent.id);
const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
return {
...talent,
id: talentId,
name: String(talent.name),
category: String(talent.category),
showIcon: true,
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
};
};
return {
gear: [...pendingGearOnPage.map((item) => ({
...item,
@@ -109,7 +122,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const icon = brandIcons.find((item) => item.entity_id === brand.id);
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' };
})],
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))],
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))].map(talentOptionWithIcon),
augmentProfiles: slots.map((slot) => ({
slot,
requiredSlots: augmentSlotCount(slot),
+1 -1
View File
@@ -86,7 +86,7 @@
<SearchableAssignment items={data.brands} fieldName="brandIds" subjectLabel="BRAND SETS" searchPlaceholder="Search by brand set name…" emptySelectionLabel="NO BRAND SETS ASSIGNED" bind:selected={editing.brandIds}/>
{#if editing.slot === 'Backpack' || editing.slot === 'Body Armor'}
<div class="selection-head"><div><span>{editing.slot.toUpperCase()} TALENTS</span><small>SELECT AT LEAST ONE · REQUIRED</small></div><b>{editing.talentIds.length} SELECTED</b></div>
<SearchableAssignment items={gearTalents(editing.slot)} fieldName="talentIds" subjectLabel={`${editing.slot.toUpperCase()} TALENTS`} searchPlaceholder={`Search ${editing.slot.toLocaleLowerCase()} talents…`} emptySelectionLabel="NO TALENTS ASSIGNED" bind:selected={editing.talentIds}/>
<SearchableAssignment items={gearTalents(editing.slot)} presentation="visual-grid" fieldName="talentIds" bind:selected={editing.talentIds}/>
{#if gearTalents(editing.slot).length === 0}<p class="error">Create an active {editing.slot} talent before adding gear for this slot.</p>{/if}
{/if}
{#if data.brands.length < 2}<p class="error">Create at least two active brand sets before adding gear.</p>{/if}
+77 -40
View File
@@ -15,11 +15,13 @@ import {
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import { normalizeWeaponModSlotPayload, validateWeaponModSlots, WEAPON_MOD_SLOT_TYPES } from '$lib/weapons/mod-slots';
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const categories: readonly string[] = WEAPON_CATEGORIES;
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage'] as const;
const modSlotTypes = ['Optics', 'Muzzle', 'Underbarrel', 'Magazine', 'Barrel', 'Upper Receiver', 'Lower Receiver', 'Trigger'] as const;
const modSlotTypes = WEAPON_MOD_SLOT_TYPES;
type ExoticEffect = { statId: string | null; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
@@ -41,6 +43,15 @@ const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
async function bonusProfileSnapshot(category: string) {
const database = await getDatabase();
return (await (await database.prepare(`SELECT bonuses.id,bonuses.weapon_category,bonuses.stat_attribute_id,
attributes.name AS attribute_name,attributes.unit,bonuses.value,bonuses.sort_order,bonuses.updated_at
FROM weapon_category_attribute_bonuses bonuses
JOIN stat_attributes attributes ON attributes.id=bonuses.stat_attribute_id
WHERE bonuses.weapon_category=? ORDER BY bonuses.sort_order`)).all(category));
}
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
@@ -76,13 +87,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])];
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, modSlots, talentOptions, augmentEffects, baseBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([
const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).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 bonuses.*,attributes.name AS attribute_name,attributes.unit
FROM weapon_category_attribute_bonuses bonuses
JOIN stat_attributes attributes ON attributes.id=bonuses.stat_attribute_id
ORDER BY bonuses.weapon_category,bonuses.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
@@ -105,6 +119,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
])
]);
const pendingTalents = pending.filter((item) => item.kind === 'talent');
const talentOptionWithIcon = (talent: Record<string, any>) => {
const talentId = String(talent.id);
const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
return {
...talent,
id: talentId,
name: String(talent.name),
showIcon: true,
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
};
};
const pendingWeapons = pending.filter((item) => item.kind === 'weapon').filter((item) =>
requestedWeaponId ? item.id === requestedWeaponId
: filter === 'standard' ? !item.isExotic
@@ -117,7 +142,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
damage_type: weapon.damageType,
is_exotic: weapon.isExotic ? 1 : 0,
reload_speed: weapon.reloadSpeed,
baseBonuses: [],
tiers: [],
modSlots: (() => { try { return JSON.parse(String(weapon.modSlots ?? '[]')); } catch { return []; } })(),
talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean),
@@ -150,7 +174,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
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: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category),
exoticTalent: talentId ? {
id: talentId,
iconUrl: (() => { const icon = talentIcons.find((item) => item.entity_id === talentId); return icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''; })(),
@@ -164,8 +188,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
}}));
return {
weapons: [...pendingWeapons, ...configuredWeapons],
talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options],
talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options],
talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options].map(talentOptionWithIcon),
talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options].map(talentOptionWithIcon),
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length },
pagination,
@@ -173,6 +197,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
search,
sort,
requestedWeaponId
,bonusProfiles: categories.map((category) => ({
category,
effects: categoryBonuses.filter((effect) => effect.weapon_category === category)
}))
,modSlotOptions: modSlotTypes.map((name) => ({ id: name, name }))
};
};
@@ -189,6 +217,43 @@ function readRange(form: FormData, tier: number, range: (typeof rangeNames)[numb
}
export const actions: Actions = {
saveBonusProfile: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const category = String(form.get('category') ?? '');
if (!categories.includes(category)) return fail(400, { bonusProfileError: 'Choose a valid weapon category.' });
const values = [1, 2, 3].map((order) => number(form, `bonusValue${order}`));
if (values.some((value) => !Number.isFinite(value) || value <= 0)) {
return fail(400, { bonusProfileError: 'Each category bonus requires a positive value.' });
}
const before = await bonusProfileSnapshot(category);
if (before.length !== 3) {
return fail(500, { bonusProfileError: `${category} requires exactly three shared bonus Attributes.` });
}
const database = await getDatabase();
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
for (let index = 0; index < before.length; index += 1) {
await (await database.prepare(
'UPDATE weapon_category_attribute_bonuses SET value=?,updated_at=? WHERE id=?'
)).run(values[index], now, before[index].id);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'weapon_bonus_profile.updated',
entityId: category,
before,
after: await bonusProfileSnapshot(category)
});
return { weaponBonusProfileSaved: true, weaponBonusProfileMode: 'edit', resultToken: randomUUID() };
},
save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
@@ -212,8 +277,7 @@ export const actions: Actions = {
const talent2Ids = [...new Set(form.getAll('talent2Ids').map(String))];
const modSlots = (() => {
try {
const parsed = JSON.parse(String(form.get('modSlots') ?? '[]'));
return Array.isArray(parsed) ? parsed.map((row) => ({ tier: Number(row.tier), slots: [...new Set(Array.isArray(row.slots) ? row.slots.map(String) : [])] })) : [];
return normalizeWeaponModSlotPayload(JSON.parse(String(form.get('modSlots') ?? '[]')));
} catch { return []; }
})();
const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
@@ -263,13 +327,8 @@ 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.' });
}
for (let tier = nativeTier; tier <= 2; tier++) {
const configured = modSlots.find((row) => row.tier === tier);
const required = tier === 0 ? 4 : 5;
if (!configured || configured.slots.length !== required || configured.slots.some((slot) => !modSlotTypes.includes(slot as typeof modSlotTypes[number]))) {
return fail(400, { weaponError: `Tier ${tier} requires exactly ${required} unique Weapon Mod slots.` });
}
}
const modSlotError = validateWeaponModSlots(nativeTier, modSlots);
if (modSlotError) return fail(400, { weaponError: modSlotError });
if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticConditionalRule)) {
return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, and modeled conditional mechanic.' });
}
@@ -304,21 +363,6 @@ export const actions: Actions = {
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.' });
}
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.' });
}
@@ -332,7 +376,6 @@ export const actions: Actions = {
}
const statIds = [...new Set([
...augmentEffects.map((effect) => effect.statId),
...baseBonuses.map((effect) => effect.statId),
...exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value))
])];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
@@ -368,7 +411,6 @@ export const actions: Actions = {
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),
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id),
exoticTalent: existingExoticTalentId ? {
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
@@ -446,12 +488,7 @@ export const actions: Actions = {
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);
}
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, baseBonuses, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } });
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } });
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
+76 -24
View File
@@ -11,8 +11,15 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import {
excludedTierZeroWeaponModSlot,
expandSharedWeaponModSlots,
sharedWeaponModSlots,
weaponModSlotsComplete
} from '$lib/weapons/mod-slots';
let { data, form } = $props();
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const categories = [...WEAPON_CATEGORIES];
const categoryOptions = categories.map((value) => ({ value, label: value, icon: value.toLowerCase() as GameIconName, iconOnly: true }));
const tierOptions = [0, 1, 2].map((value) => ({ value: String(value), label: `T${value}` }));
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
@@ -24,8 +31,9 @@
clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'equipped_weapon',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{I:{value:0},II:{value:0}}}]}]
});
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', conditionalRule: blankConditionalRule() });
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)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
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)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null);
let bonusEditing = $state<any | null>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
let openedRequestedWeapon = $state(false);
@@ -35,10 +43,24 @@
return { ...blankTier(tier), ...saved };
});
const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] }));
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, 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, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() };
const sharedSlots = sharedWeaponModSlots(modSlots);
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, sharedModSlots: sharedSlots, t0ExcludedSlot: excludedTierZeroWeaponModSlot(sharedSlots, modSlots), talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() };
bonusEditing = null;
};
const editBonusProfile = (profile: any) => {
bonusEditing = { category: profile.category, effects: profile.effects.map((effect: any) => ({ ...effect })) };
editing = null;
};
const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier);
const modSlotsAt = (tier: number) => editing.modSlots.find((item: any) => Number(item.tier) === tier);
const modSlotPayload = () => expandSharedWeaponModSlots(editing.sharedModSlots, editing.t0ExcludedSlot);
const t0SlotCount = () => editing.t0ExcludedSlot ? Math.max(0, editing.sharedModSlots.length - 1) : 0;
const weaponState = (weapon: any) => weapon.pending
? 'PENDING'
: Number(weapon.active) !== 1
? 'ARCHIVED'
: weaponModSlotsComplete(Number(weapon.native_tier), weapon.modSlots)
? 'ACTIVE'
: 'CONFIG REQUIRED';
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) => {
@@ -74,13 +96,10 @@
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 (editing?.t0ExcludedSlot && !editing.sharedModSlots.includes(editing.t0ExcludedSlot)) {
editing.t0ExcludedSlot = '';
}
if (!openedRequestedWeapon && data.requestedWeaponId) {
const requested = data.weapons.find((weapon) => weapon.id === data.requestedWeaponId);
if (requested) edit(requested);
@@ -91,11 +110,42 @@
if (form.weaponMode === 'add') iconResetToken += 1;
editing = form.weaponMode === 'add' ? blank() : null;
}
if (form?.weaponBonusProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
bonusEditing = 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-toolbar"><div><p class="eyebrow">ARMAMENT DEFINITION REGISTRY</p><h2>WEAPONS</h2><p>Configure shared category bonuses, fixed handling, tier progression, talent pools, and tactical augmentations.</p></div><button class="primary" onclick={() => { editing = blank(); bonusEditing = null; }}>ADD WEAPON <span>+</span></button></div>
<details class="gear-augment-registry weapon-bonus-registry">
<summary class="selection-head"><div><span>SHARED WEAPON CATEGORY BONUSES</span><small>INHERITED BY STANDARD AND EXOTIC WEAPONS</small></div><div><b>{data.bonusProfiles.filter((profile) => profile.effects.length === 3).length} / 6 CONFIGURED</b><i aria-hidden="true">+</i></div></summary>
<div class="gear-augment-profile-grid weapon-bonus-profile-grid">
{#each data.bonusProfiles as profile}
<button type="button" class:configured={profile.effects.length === 3} onclick={() => editBonusProfile(profile)}>
<header><div><strong>{profile.category}</strong><small>BASE WEAPON TYPE</small></div><span>{profile.effects.length === 3 ? 'CONFIGURED' : 'REQUIRED'}</span></header>
<div>{#each profile.effects as effect}<span><small>0{effect.sort_order}</small><b>{effect.attribute_name}</b><em>{effect.value}{effect.unit === 'flat' ? '' : effect.unit}</em></span>{/each}</div>
</button>
{/each}
</div>
</details>
{#if bonusEditing}
<div class="dashboard-editor weapon-bonus-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CATEGORY BONUSES</p><h3>{bonusEditing.category}</h3></div><button aria-label="Close weapon category bonus editor" onclick={() => bonusEditing = null}>×</button></div>
<PendingForm action="?/saveBonusProfile">
<input type="hidden" name="category" value={bonusEditing.category}/>
<p class="console-note">Every {bonusEditing.category} inherits these bonuses, including exotic weapons in this category.</p>
<div class="weapon-bonus-fields">
{#each bonusEditing.effects as effect, index}
<label class="field">{effect.attribute_name}<input name={`bonusValue${index + 1}`} type="number" min="0.01" step="any" bind:value={effect.value} required/></label>
{/each}
</div>
{#if form?.bonusProfileError}<p class="error">{form.bonusProfileError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => bonusEditing = null}>CANCEL</button><button class="primary">SAVE CATEGORY BONUSES →</button></div>
</PendingForm>
</div>
{/if}
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL WEAPONS',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'standard',label:'STANDARD',count:data.summary.standard,description:'Non-exotic weapons'},{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Exotic weapons'}]}/>
{#snippet weaponEditor()}
@@ -103,7 +153,7 @@
<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" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="modSlots" value={JSON.stringify(editing.modSlots)}/>
<input type="hidden" name="modSlots" value={JSON.stringify(modSlotPayload())}/>
<div class="weapon-identity-grid">
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
<SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} onchange={(values) => setCategory(values[0])} required/>
@@ -140,27 +190,29 @@
<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><div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName={`baseBonusStat${index + 1}`} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={bonus.stat_attribute_id}/></div><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 RANGES · CORE ATTRIBUTES USE GLOBAL CONFIGURATION</small></div><b>3 TIERS REQUIRED</b></div>
<p class="weapon-mod-slot-note"><b>MOD SLOTS OPTIONAL</b><span>T1 and T2 share one slot set. T0 uses that set minus one slot. ISAC marks the weapon Config Required until you can verify both parts.</span></p>
<div class="weapon-tier-grid">
{#each [0, 1, 2] as tier}
{@const slotConfig = modSlotsAt(tier)}
<fieldset class="weapon-tier-card" class:unavailable={tier < Number(editing.native_tier)} disabled={tier < Number(editing.native_tier)}><legend>TIER {tier}</legend>
{#if tier < Number(editing.native_tier)}<p class="tier-unavailable">BELOW NATIVE TIER · NOT AVAILABLE</p>{/if}
<div class="weapon-range-row"><strong><GameIcon name={iconName(editing.damage_type)} decorative/><span>DMG<small>{editing.damage_type.toUpperCase()} DAMAGE</small></span></strong><label>MIN<input name={`tier${tier}damageMin`} type="number" min="0" step="any" value={tierAt(tier).damage_min} oninput={(event) => tierAt(tier).damage_min = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}damageMax`} type="number" min="0" step="any" value={tierAt(tier).damage_max} oninput={(event) => tierAt(tier).damage_max = event.currentTarget.valueAsNumber} required/></label></div>
<div class="weapon-mod-slot-config"><div class="selection-head"><div><span>WEAPON MOD SLOTS</span><small>{tier === 0 ? 'EXACTLY FOUR SLOTS' : 'EXACTLY FIVE SLOTS'}</small></div><b>{slotConfig.slots.length} / {tier === 0 ? 4 : 5}</b></div><SearchableAssignment items={data.modSlotOptions} subjectLabel="MOD SLOTS" searchPlaceholder="Search slot types…" bind:selected={slotConfig.slots}/></div>
</fieldset>
{/each}
</div>
<section class="weapon-mod-slot-config">
<div class="selection-head"><div><span>{Number(editing.native_tier) === 2 ? 'T2 WEAPON MOD SLOTS' : 'SHARED T1 / T2 WEAPON MOD SLOTS'}</span><small>SELECT UP TO FIVE VERIFIED SLOTS</small></div><b class:config-required={editing.sharedModSlots.length !== 5}>{editing.sharedModSlots.length} / 5</b></div>
<SearchableAssignment items={data.modSlotOptions} presentation="toggle-grid" subjectLabel="SHARED MOD SLOTS" bind:selected={editing.sharedModSlots}/>
{#if Number(editing.native_tier) === 0}
<div class="weapon-t0-exclusion">
<div class="selection-head"><div><span>T0 SLOT NOT PRESENT</span><small>CHOOSE THE ONE SHARED SLOT OMITTED AT T0</small></div><b class:config-required={!editing.t0ExcludedSlot}>{editing.t0ExcludedSlot ? `${t0SlotCount()} / 4` : 'NOT VERIFIED'}</b></div>
<SegmentedChoice legend="T0 EXCLUSION" name="t0ExcludedSlot" options={[{value:'',label:'NOT VERIFIED'}, ...editing.sharedModSlots.map((slot: string) => ({value:slot,label:slot}))]} columns={Math.min(3, editing.sharedModSlots.length + 1)} bind:value={editing.t0ExcludedSlot}/>
</div>
{/if}
</section>
<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>TALENT 1 · TOP POOL</span><small>GLOBALLY CLASSIFIED FOR FIRST SLOT</small></div><b>{editing.talent1Ids.length}</b></div><SearchableAssignment items={data.talent1Options} presentation="visual-grid" 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} presentation="visual-grid" 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">
@@ -185,7 +237,7 @@
<div class="dashboard-table weapon-dashboard-table">
<div class="dashboard-row weapon-row table-heading"><SortableColumnHeader label="WEAPON" column="name" sort={data.sort}/><SortableColumnHeader label="CLASS" column="class" sort={data.sort}/><SortableColumnHeader label="FIXED ATTRIBUTES" column="fixed" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TIER RANGE" column="tiers" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TALENT POOLS" column="talents" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="AUGMENTS" column="augments" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.weapons as weapon}
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending} class:registry-row-editing={editing?.id === weapon.id}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></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:pending={weapon.pending} class="state-dot">{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT</button>{/if}</div>
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending} class:registry-row-editing={editing?.id === weapon.id}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></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:pending={weapon.pending} class:config-required={weaponState(weapon) === 'CONFIG REQUIRED'} class="state-dot">{weaponState(weapon)}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT</button>{/if}</div>
{#if editing?.id === weapon.id}
<RegistryInlineEditor id={`weapon-editor-${weapon.id}`} label={`Edit ${weapon.name}`}>
{@render weaponEditor()}