feat(database): add weapon discovery filters

This commit is contained in:
JB
2026-07-26 00:49:28 -04:00
parent 317e5b39bd
commit 81784f6f02
4 changed files with 84 additions and 10 deletions
+7
View File
@@ -1245,6 +1245,13 @@ sets such as Brands and Talents. Always expose selected counts, a single clear
action, `aria-pressed` state, keyboard focus, and explicit text explaining the
combination rule. At intermediate width, the bounded facet spans the row above
two searchable facets; on narrow screens all facet groups stack vertically.
Weapon discovery applies this pattern to weapon-family icons, damage-type
icons, and the searchable pool of compatible Talents. Talent facet rows show
the rendered Tier I description beneath the name. Talent searches match both
fields case-insensitively so mechanical phrases can discover talents even when
the talent name is unknown. Brand facets similarly index the names of all
set-bonus Attributes, allowing players to search by the desired mechanic
without exposing that metadata as extra visual clutter.
### Build persistence and sharing
+2
View File
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
import ItemIcon from './ItemIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
type Option = { value: string; label: string; iconUrl?: string; gameIcon?: GameIconName };
type Option = { value: string; label: string; description?: string; searchText?: string; iconUrl?: string; gameIcon?: GameIconName };
type Group = { key: string; label: string; kind: 'segmented' | 'searchable'; options: Option[]; selected: string[] };
let { groups }: { groups: Group[] } = $props();
let queries = $state<Record<string, string>>({});
@@ -26,7 +26,10 @@
};
const visible = (group: Group) => {
const query = (queries[group.key] ?? '').normalize('NFKC').trim().toLocaleLowerCase();
return group.options.filter((option) => !query || option.label.toLocaleLowerCase().includes(query));
return group.options.filter((option) => !query
|| option.label.toLocaleLowerCase().includes(query)
|| option.description?.toLocaleLowerCase().includes(query)
|| option.searchText?.toLocaleLowerCase().includes(query));
};
const selectedCount = $derived(groups.reduce((total, group) => total + group.selected.length, 0));
</script>
@@ -44,7 +47,7 @@
{#each visible(group) as option}
<button type="button" class:selected={group.selected.includes(option.value)} aria-pressed={group.selected.includes(option.value)} onclick={() => change(group.key, option.value)}>
{#if option.gameIcon}<GameIcon name={option.gameIcon} decorative/>{:else if option.iconUrl !== undefined}<ItemIcon src={option.iconUrl} alt=""/>{:else}<i aria-hidden="true"></i>{/if}
<span>{option.label}</span>
<span>{option.label}{#if option.description}<small><b>T1</b>{option.description}</small>{/if}</span>
</button>
{:else}
<small>NO MATCHING OPTIONS</small>
+69 -7
View File
@@ -14,7 +14,7 @@ const valueWithUnit = (value: unknown, unit: unknown) => `${number(value)}${unit
const range = (min: unknown, max: unknown, unit: unknown = '') => `${valueWithUnit(min, unit)}–${valueWithUnit(max, unit)}`;
const list = (value: unknown, fallback = 'NONE') => typeof value === 'string' && value ? value : fallback;
const gearIcon = (slot: unknown) => String(slot).toLowerCase().replaceAll(' ', '_') as GameIconName;
const weaponIcon = (category: unknown) => String(category).toLowerCase();
const weaponIcon = (category: unknown) => String(category).toLowerCase() as GameIconName;
const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
@@ -31,7 +31,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
label: string;
kind: 'segmented' | 'searchable';
selected: string[];
options: Array<{ value: string; label: string; iconUrl?: string; gameIcon?: GameIconName }>;
options: Array<{ value: string; label: string; description?: string; searchText?: string; iconUrl?: string; gameIcon?: GameIconName }>;
}> = [];
let countSql = '';
let rowsSql = '';
@@ -39,10 +39,17 @@ export const load: PageServerLoad = async ({ params, url }) => {
switch (category.slug) {
case 'gear': {
const [brandRows, talentRows] = await Promise.all([
(await database.prepare(`SELECT brands.id,brands.name,icons.updated_at FROM brand_sets brands
(await database.prepare(`SELECT brands.id,brands.name,
(SELECT GROUP_CONCAT(DISTINCT attributes.name)
FROM brand_set_effects effects
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
WHERE effects.brand_set_id=brands.id) AS bonus_attributes,
icons.updated_at FROM brand_sets brands
LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=brands.id
WHERE brands.active=1 ORDER BY brands.name COLLATE NOCASE`)).all(),
(await database.prepare(`SELECT talents.id,talents.name,icons.updated_at FROM talents
(await database.prepare(`SELECT talents.id,talents.name,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
icons.updated_at FROM talents
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
WHERE talents.active=1 AND talents.category IN ('Backpack','Body Armor','Vest')
ORDER BY talents.name COLLATE NOCASE`)).all()
@@ -86,6 +93,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
key: 'brand', label: 'BRAND SETS', kind: 'searchable', selected: selectedBrands,
options: brandRows.map((row) => ({
value: String(row.id), label: String(row.name),
searchText: String(row.bonus_attributes ?? ''),
iconUrl: iconUrl('brand_set', row.id, row.updated_at)
}))
},
@@ -93,14 +101,49 @@ export const load: PageServerLoad = async ({ params, url }) => {
key: 'talent', label: 'TALENTS', kind: 'searchable', selected: selectedTalents,
options: talentRows.map((row) => ({
value: String(row.id), label: String(row.name),
description: String(row.tier_i_description ?? ''),
iconUrl: iconUrl('talent', row.id, row.updated_at)
}))
}
];
break;
}
case 'weapons':
countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE item.active=1${searchClause}`;
case 'weapons': {
const talentRows = await (await database.prepare(`SELECT DISTINCT talents.id,talents.name,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
icons.updated_at
FROM weapon_talent_options options
JOIN weapons source_weapons ON source_weapons.id=options.weapon_id AND source_weapons.active=1
JOIN talents ON talents.id=options.talent_id AND talents.active=1
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
ORDER BY talents.name COLLATE NOCASE`)).all();
const weaponTypes = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const validTalents = new Set(talentRows.map((row) => String(row.id)));
const selectedTypes = url.searchParams.getAll('weaponType').filter((value) => weaponTypes.includes(value));
const selectedDamageTypes = url.searchParams.getAll('damageType').filter((value) => damageTypes.includes(value));
const selectedTalents = url.searchParams.getAll('weaponTalent').filter((value) => validTalents.has(value));
const conditions = ['item.active=1'];
queryArgs = [];
if (search) {
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
queryArgs.push(pattern);
}
if (selectedTypes.length) {
conditions.push(`item.category IN (${selectedTypes.map(() => '?').join(',')})`);
queryArgs.push(...selectedTypes);
}
if (selectedDamageTypes.length) {
conditions.push(`item.damage_type IN (${selectedDamageTypes.map(() => '?').join(',')})`);
queryArgs.push(...selectedDamageTypes);
}
if (selectedTalents.length) {
conditions.push(`EXISTS (SELECT 1 FROM weapon_talent_options filter_talents
WHERE filter_talents.weapon_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`);
queryArgs.push(...selectedTalents);
}
const where = conditions.join(' AND ');
countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE ${where}`;
rowsSql = `SELECT item.*,
native.damage_min,native.damage_max,native.reference_level,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
@@ -108,9 +151,28 @@ export const load: PageServerLoad = async ({ params, url }) => {
(SELECT COUNT(*) FROM weapon_base_attribute_bonuses bonuses WHERE bonuses.weapon_id=item.id) AS bonus_count
FROM weapons item
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
WHERE item.active=1${searchClause}
WHERE ${where}
ORDER BY item.is_exotic DESC,item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
filters = [
{
key: 'weaponType', label: 'WEAPON TYPE', kind: 'segmented', selected: selectedTypes,
options: weaponTypes.map((type) => ({ value: type, label: type, gameIcon: weaponIcon(type) }))
},
{
key: 'damageType', label: 'DAMAGE TYPE', kind: 'segmented', selected: selectedDamageTypes,
options: damageTypes.map((type) => ({ value: type, label: type, gameIcon: weaponIcon(type) }))
},
{
key: 'weaponTalent', label: 'TALENT POOL', kind: 'searchable', selected: selectedTalents,
options: talentRows.map((row) => ({
value: String(row.id), label: String(row.name),
description: String(row.tier_i_description ?? ''),
iconUrl: iconUrl('talent', row.id, row.updated_at)
}))
}
];
break;
}
case 'weapon-mods':
countSql = `SELECT COUNT(*) AS total FROM weapon_mods item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,