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 action, `aria-pressed` state, keyboard focus, and explicit text explaining the
combination rule. At intermediate width, the bounded facet spans the row above combination rule. At intermediate width, the bounded facet spans the row above
two searchable facets; on narrow screens all facet groups stack vertically. 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 ### 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 ItemIcon from './ItemIcon.svelte';
import type { GameIconName } from '$lib/game-icons'; 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[] }; type Group = { key: string; label: string; kind: 'segmented' | 'searchable'; options: Option[]; selected: string[] };
let { groups }: { groups: Group[] } = $props(); let { groups }: { groups: Group[] } = $props();
let queries = $state<Record<string, string>>({}); let queries = $state<Record<string, string>>({});
@@ -26,7 +26,10 @@
}; };
const visible = (group: Group) => { const visible = (group: Group) => {
const query = (queries[group.key] ?? '').normalize('NFKC').trim().toLocaleLowerCase(); 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)); const selectedCount = $derived(groups.reduce((total, group) => total + group.selected.length, 0));
</script> </script>
@@ -44,7 +47,7 @@
{#each visible(group) as option} {#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)}> <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} {#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> </button>
{:else} {:else}
<small>NO MATCHING OPTIONS</small> <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 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 list = (value: unknown, fallback = 'NONE') => typeof value === 'string' && value ? value : fallback;
const gearIcon = (slot: unknown) => String(slot).toLowerCase().replaceAll(' ', '_') as GameIconName; 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) => const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : ''; updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
@@ -31,7 +31,7 @@ export const load: PageServerLoad = async ({ params, url }) => {
label: string; label: string;
kind: 'segmented' | 'searchable'; kind: 'segmented' | 'searchable';
selected: string[]; 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 countSql = '';
let rowsSql = ''; let rowsSql = '';
@@ -39,10 +39,17 @@ export const load: PageServerLoad = async ({ params, url }) => {
switch (category.slug) { switch (category.slug) {
case 'gear': { case 'gear': {
const [brandRows, talentRows] = await Promise.all([ 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 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(), 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 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') WHERE talents.active=1 AND talents.category IN ('Backpack','Body Armor','Vest')
ORDER BY talents.name COLLATE NOCASE`)).all() 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, key: 'brand', label: 'BRAND SETS', kind: 'searchable', selected: selectedBrands,
options: brandRows.map((row) => ({ options: brandRows.map((row) => ({
value: String(row.id), label: String(row.name), value: String(row.id), label: String(row.name),
searchText: String(row.bonus_attributes ?? ''),
iconUrl: iconUrl('brand_set', row.id, row.updated_at) 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, key: 'talent', label: 'TALENTS', kind: 'searchable', selected: selectedTalents,
options: talentRows.map((row) => ({ options: talentRows.map((row) => ({
value: String(row.id), label: String(row.name), value: String(row.id), label: String(row.name),
description: String(row.tier_i_description ?? ''),
iconUrl: iconUrl('talent', row.id, row.updated_at) iconUrl: iconUrl('talent', row.id, row.updated_at)
})) }))
} }
]; ];
break; break;
} }
case 'weapons': case 'weapons': {
countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE item.active=1${searchClause}`; 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.*, rowsSql = `SELECT item.*,
native.damage_min,native.damage_max,native.reference_level, 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, (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 (SELECT COUNT(*) FROM weapon_base_attribute_bonuses bonuses WHERE bonuses.weapon_id=item.id) AS bonus_count
FROM weapons item FROM weapons item
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier 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 ?`; 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; break;
}
case 'weapon-mods': case 'weapon-mods':
countSql = `SELECT COUNT(*) AS total FROM weapon_mods item WHERE item.active=1${searchClause}`; countSql = `SELECT COUNT(*) AS total FROM weapon_mods item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon, rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,