feat(database): add weapon discovery filters
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user