From 9432d44b484f28f7fa0340a494cd31a64c6b53da Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 2 Aug 2026 00:22:10 +0200 Subject: [PATCH] weaponType search --- .../semantic-guide.component.html | 35 ++++++++++ .../semantic-guide.component.ts | 2 +- src/app/equipment-handlers/apollo.handler.ts | 2 +- .../bombast-laser.handler.spec.ts | 3 +- .../bombast-laser.handler.ts | 3 +- .../equipment-handlers/hag.handler.spec.ts | 3 +- src/app/equipment-handlers/hag.handler.ts | 3 +- .../ppc-capacitor.handler.ts | 2 +- src/app/models/equipment.model.ts | 4 +- src/app/models/mounted-equipment.model.ts | 3 +- src/app/models/rules/unit-type-rules.ts | 2 +- src/app/models/units.model.ts | 3 + src/app/models/weapon-types.model.spec.ts | 18 +++++ src/app/models/weapon-types.model.ts | 63 +++++++++++++++++ .../equipment-interaction-registry.service.ts | 3 +- src/app/services/unit-search-filters.model.ts | 6 +- .../services/unit-search-filters.service.ts | 17 +++-- .../unit-search-index.service.spec.ts | 69 +++++++++++++++++++ src/app/services/unit-search-index.service.ts | 30 ++++++++ .../utils/inventory-control-damage.util.ts | 3 +- src/app/utils/inventory-control.util.ts | 3 +- src/app/utils/semantic-filter-ast.util.ts | 5 +- src/app/utils/semantic-filter.util.ts | 9 +-- src/app/utils/unit-filter-kernel.util.ts | 13 ++-- ...it-search-adv-options-builder.util.spec.ts | 60 ++++++++++++++++ .../unit-search-adv-options-builder.util.ts | 25 ++++--- src/app/utils/unit-search-adv-options.util.ts | 25 ++++--- .../utils/unit-search-executor.util.spec.ts | 49 +++++++++++++ src/app/utils/unit-search-executor.util.ts | 5 +- .../utils/unit-search-filter-config.util.ts | 11 ++- src/app/utils/unit-search-shared.util.ts | 28 ++++++++ src/app/utils/unit-search-url-filters.util.ts | 5 +- .../unit-search-worker-request.util.spec.ts | 21 ++++++ 33 files changed, 472 insertions(+), 61 deletions(-) create mode 100644 src/app/models/weapon-types.model.spec.ts create mode 100644 src/app/models/weapon-types.model.ts diff --git a/src/app/components/semantic-guide/semantic-guide.component.html b/src/app/components/semantic-guide/semantic-guide.component.html index 75f68aa48..948a44ff9 100644 --- a/src/app/components/semantic-guide/semantic-guide.component.html +++ b/src/app/components/semantic-guide/semantic-guide.component.html @@ -88,10 +88,45 @@

Quantity Constraints

equipment=PPC:2-4→ Between 2 and 4 PPCs
equipment=PPC:!3→ Not exactly 3 PPCs
equipment&=AC5:>2,CASE→ More than 2 AC5s and has CASE (must have both)
+
weaponType=AI:>=2→ At least 2 anti-infantry weapons
+
weaponType&=M:>=2,F→ At least 2 missile weapons and at least 1 flak weapon
} + +
+

Classic BattleTech Weapon Types

+

weaponType matches intrinsic weapon properties and supports OR, AND, NOT, and quantity constraints.

+
+
weaponType=AI→ Has an anti-infantry weapon
+
weaponType=DB,DE→ Has a direct-fire ballistic OR energy weapon
+
weaponType&=M,F→ Has both missile and flak weapons
+
weaponType!=OS→ Excludes units with one-shot weapons
+
+

Codes: + A Artillery, + AE Area-Effect, + AI Anti-Infantry, + B Ballistic, + C Cluster, + DB Direct-Fire Ballistic, + DE Direct-Fire Energy, + E Energy, + F Flak, + H Heat-Causing, + M Missile, + N Nuclear, + OS One-Shot, + P Pulse, + PB Point-Blank, + R Rapid-Fire, + S Switchable Ammo, + V Variable Damage, + X Explosive. +

+
+

Alpha Strike Specials

diff --git a/src/app/components/semantic-guide/semantic-guide.component.ts b/src/app/components/semantic-guide/semantic-guide.component.ts index fb5db7dcf..ec9766579 100644 --- a/src/app/components/semantic-guide/semantic-guide.component.ts +++ b/src/app/components/semantic-guide/semantic-guide.component.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 The MegaMek Team. All Rights Reserved. + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. * * This file is part of MekBay. * diff --git a/src/app/equipment-handlers/apollo.handler.ts b/src/app/equipment-handlers/apollo.handler.ts index 13c79018d..281239cd3 100644 --- a/src/app/equipment-handlers/apollo.handler.ts +++ b/src/app/equipment-handlers/apollo.handler.ts @@ -1,5 +1,5 @@ import type { PickerChoice } from '../components/picker/picker.interface'; -import type { WeaponType } from '../models/equipment.model'; +import type { WeaponType } from '../models/weapon-types.model'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { ToHitAdjustment } from '../models/rules/game-rules'; import { EquipmentInteractionHandler, type HandlerContext, type ToHitAdjustmentContext } from '../services/equipment-interaction-registry.service'; diff --git a/src/app/equipment-handlers/bombast-laser.handler.spec.ts b/src/app/equipment-handlers/bombast-laser.handler.spec.ts index b1713fe00..ef056f78a 100644 --- a/src/app/equipment-handlers/bombast-laser.handler.spec.ts +++ b/src/app/equipment-handlers/bombast-laser.handler.spec.ts @@ -1,5 +1,6 @@ import type { PickerChoice } from '../components/picker/picker.interface'; -import { MiscEquipment, WeaponEquipment, type WeaponDamage, type WeaponType } from '../models/equipment.model'; +import { MiscEquipment, WeaponEquipment, type WeaponDamage } from '../models/equipment.model'; +import type { WeaponType } from '../models/weapon-types.model'; import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; import { EquipmentInteractionRegistry, type HandlerContext } from '../services/equipment-interaction-registry.service'; diff --git a/src/app/equipment-handlers/bombast-laser.handler.ts b/src/app/equipment-handlers/bombast-laser.handler.ts index 61629f95f..2bae868e3 100644 --- a/src/app/equipment-handlers/bombast-laser.handler.ts +++ b/src/app/equipment-handlers/bombast-laser.handler.ts @@ -1,6 +1,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import type { EquipmentFlag } from '../models/equipment-flags.type'; -import { WeaponEquipment, type WeaponDamage, type WeaponType } from '../models/equipment.model'; +import { WeaponEquipment, type WeaponDamage } from '../models/equipment.model'; +import type { WeaponType } from '../models/weapon-types.model'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { ToHitAdjustment } from '../models/rules/game-rules'; import { diff --git a/src/app/equipment-handlers/hag.handler.spec.ts b/src/app/equipment-handlers/hag.handler.spec.ts index cf89ea7a6..8ab892100 100644 --- a/src/app/equipment-handlers/hag.handler.spec.ts +++ b/src/app/equipment-handlers/hag.handler.spec.ts @@ -1,4 +1,5 @@ -import { MiscEquipment, WeaponEquipment, type WeaponType } from '../models/equipment.model'; +import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; +import type { WeaponType } from '../models/weapon-types.model'; import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; diff --git a/src/app/equipment-handlers/hag.handler.ts b/src/app/equipment-handlers/hag.handler.ts index 3e4c9dfce..ccaa1a129 100644 --- a/src/app/equipment-handlers/hag.handler.ts +++ b/src/app/equipment-handlers/hag.handler.ts @@ -33,7 +33,8 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { EquipmentFlag } from '../models/equipment-flags.type'; -import { WeaponEquipment, type WeaponType } from '../models/equipment.model'; +import { WeaponEquipment } from '../models/equipment.model'; +import type { WeaponType } from '../models/weapon-types.model'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { ToHitAdjustment } from '../models/rules/game-rules'; import { EquipmentInteractionHandler, type HandlerContext, type ToHitAdjustmentContext } from '../services/equipment-interaction-registry.service'; diff --git a/src/app/equipment-handlers/ppc-capacitor.handler.ts b/src/app/equipment-handlers/ppc-capacitor.handler.ts index 182df9eab..39eb1dedf 100644 --- a/src/app/equipment-handlers/ppc-capacitor.handler.ts +++ b/src/app/equipment-handlers/ppc-capacitor.handler.ts @@ -7,7 +7,7 @@ import type { WeaponDamage } from '../models/equipment.model'; import { isPpcCapacitorCompatibleWeapon } from '../models/entity/utils/equipment-link-rules'; import type { InventoryControlDamageContext } from '../utils/inventory-control-damage.util'; import type { InventoryControlHeatEffect } from '../utils/inventory-control-heat.util'; -import type { WeaponType } from '../models/equipment.model'; +import type { WeaponType } from '../models/weapon-types.model'; import { EquipmentFlag } from '../models/equipment-flags.type'; export const PPC_CAPACITOR_STATE_KEY = 'ppc_capacitor_state'; diff --git a/src/app/models/equipment.model.ts b/src/app/models/equipment.model.ts index 45e463463..401ae074d 100644 --- a/src/app/models/equipment.model.ts +++ b/src/app/models/equipment.model.ts @@ -55,6 +55,7 @@ import { resolveAmmoWeaponProfile, type AmmoWeaponProfile } from './ammo-weapon- import type { EquipmentFlag } from './equipment-flags.type'; import { AmmoMunitionFlag } from './ammo-munition-flags.type'; import type { EquipmentRegistry } from './equipment-lookup'; +import { WEAPON_TYPES, type WeaponType } from './weapon-types.model'; /* * Author: Drake @@ -77,9 +78,6 @@ export interface WeaponDamage { readonly maximum: number; readonly unit?: WeaponDamageUnit; } -export const WEAPON_TYPES = ['A', 'AE', 'AI', 'B', 'C', 'DB', 'DE', 'E', 'F', 'H', 'M', 'N', 'OS', 'P', 'PB', 'R', 'S', 'V', 'X'] as const; -export type WeaponType = typeof WEAPON_TYPES[number]; - // ============================================================================ // Ammo Types // ============================================================================ diff --git a/src/app/models/mounted-equipment.model.ts b/src/app/models/mounted-equipment.model.ts index e94b0884b..67013517b 100644 --- a/src/app/models/mounted-equipment.model.ts +++ b/src/app/models/mounted-equipment.model.ts @@ -34,7 +34,8 @@ import { computed, signal, type Signal, type WritableSignal } from '@angular/core'; import type { CBTForceUnit } from './cbt-force-unit.model'; -import { AmmoEquipment, MiscEquipment, WEAPON_TYPES, WeaponEquipment, type Equipment, type WeaponType } from './equipment.model'; +import { AmmoEquipment, MiscEquipment, WeaponEquipment, type Equipment } from './equipment.model'; +import { WEAPON_TYPES, type WeaponType } from './weapon-types.model'; import type { CriticalSlot } from './force-serialization'; import type { MountedEquipmentRuleState } from './rules/unit-type-rules'; import { isPhysicalWeaponEquipment } from './entity/utils/physical-weapon'; diff --git a/src/app/models/rules/unit-type-rules.ts b/src/app/models/rules/unit-type-rules.ts index 18a2b56e5..cd7b7059e 100644 --- a/src/app/models/rules/unit-type-rules.ts +++ b/src/app/models/rules/unit-type-rules.ts @@ -34,7 +34,7 @@ import { computed, signal, type Signal } from '@angular/core'; import { MountedWeapon, type MountedEquipment } from '../mounted-equipment.model'; import type { ToHitModifierBreakdownEntry } from './game-rules'; -import type { WeaponType } from '../equipment.model'; +import type { WeaponType } from '../weapon-types.model'; import type { CriticalSlot, SerializedC3NetworkGroup } from '../force-serialization'; import { getMotiveModeLabel, type MotiveModes } from '../motiveModes.model'; import type { TurnState } from '../turn-state.model'; diff --git a/src/app/models/units.model.ts b/src/app/models/units.model.ts index 93b8b6a7c..6788cbd99 100644 --- a/src/app/models/units.model.ts +++ b/src/app/models/units.model.ts @@ -35,6 +35,7 @@ * Author: Drake */ import type { Equipment } from "./equipment.model"; +import type { WeaponType } from './weapon-types.model'; import type { Era } from "./eras.model"; import type { ComponentTechLevel, MoveType, UnitSubtype, UnitType } from "./entity/types"; import { TechBase } from "./tech.model"; @@ -222,6 +223,8 @@ export interface Unit { _dissipationEfficiency: number; // Dissipation - Heat _mdSumNoPhysical: number; // Max damage sum for all weapons except physical _mdSumNoPhysicalNoOneshots: number; // Max damage sum for all weapons except physical, ignoring oneshots + _weaponTypes?: WeaponType[]; // Intrinsic types present on mounted weapons + _weaponTypeCounts?: Partial>; // Mounted quantity by intrinsic weapon type _era?: Era; // Cached era for this unit _nameTags: UnitTagEntry[]; // Quantity-aware tags assigned to this specific unit name _chassisTags: UnitTagEntry[]; // Quantity-aware tags assigned to the chassis (applies to all variants) diff --git a/src/app/models/weapon-types.model.spec.ts b/src/app/models/weapon-types.model.spec.ts new file mode 100644 index 000000000..9029bba45 --- /dev/null +++ b/src/app/models/weapon-types.model.spec.ts @@ -0,0 +1,18 @@ +import { normalizeWeaponType } from './weapon-types.model'; + +describe('normalizeWeaponType', () => { + it('normalizes canonical values across casing and whitespace', () => { + expect(normalizeWeaponType(' ai ')).toBe('AI'); + expect(normalizeWeaponType('db')).toBe('DB'); + }); + + it('normalizes the legacy AP alias to AI', () => { + expect(normalizeWeaponType('AP')).toBe('AI'); + expect(normalizeWeaponType(' ap ')).toBe('AI'); + }); + + it('preserves normalized unknown values for downstream validation', () => { + expect(normalizeWeaponType(' unknown ')).toBe('UNKNOWN'); + expect(normalizeWeaponType(' ')).toBe(''); + }); +}); \ No newline at end of file diff --git a/src/app/models/weapon-types.model.ts b/src/app/models/weapon-types.model.ts new file mode 100644 index 000000000..5b9d71cf4 --- /dev/null +++ b/src/app/models/weapon-types.model.ts @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 The MegaMek Team. All Rights Reserved. + * + * This file is part of MekBay. + * + * MekBay is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPL), + * version 3 or (at your option) any later version, + * as published by the Free Software Foundation. + * + * MekBay is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * + * A copy of the GPL should have been included with this project; + * if not, see . + * + * NOTICE: The MegaMek organization is a non-profit group of volunteers + * creating free software for the BattleTech community. + * + * MechWarrior, BattleMech, `Mech and AeroTech are registered trademarks + * of The Topps Company, Inc. All Rights Reserved. + * + * Catalyst Game Labs and the Catalyst Game Labs logo are trademarks of + * InMediaRes Productions, LLC. + * + * MechWarrior Copyright Microsoft Corporation. MegaMek was created under + * Microsoft's "Game Content Usage Rules" + * and it is not endorsed by or + * affiliated with Microsoft. + */ + +export const WEAPON_TYPES = ['A', 'AE', 'AI', 'B', 'C', 'DB', 'DE', 'E', 'F', 'H', 'M', 'N', 'OS', 'P', 'PB', 'R', 'S', 'V', 'X'] as const; +export type WeaponType = typeof WEAPON_TYPES[number]; + +export const WEAPON_TYPE_DISPLAY_NAMES: Readonly> = { + A: 'Artillery', + AE: 'Area-Effect', + AI: 'Anti-Infantry', + B: 'Ballistic', + C: 'Cluster', + DB: 'Direct-Fire, Ballistic', + DE: 'Direct-Fire, Energy', + E: 'Energy', + F: 'Flak', + H: 'Heat-Causing', + M: 'Missile', + N: 'Nuclear', + OS: 'One-Shot', + P: 'Pulse', + PB: 'Point-Blank', + R: 'Rapid-Fire', + S: 'Switchable Ammo', + V: 'Variable Damage', + X: 'Explosive', +}; + +/** Converts supported aliases and case variants to their canonical weapon type. */ +export function normalizeWeaponType(value: string): string { + const normalized = value.trim().toUpperCase(); + return normalized === 'AP' ? 'AI' : normalized; +} diff --git a/src/app/services/equipment-interaction-registry.service.ts b/src/app/services/equipment-interaction-registry.service.ts index ca6f3ea1b..ce0bf55c2 100644 --- a/src/app/services/equipment-interaction-registry.service.ts +++ b/src/app/services/equipment-interaction-registry.service.ts @@ -37,7 +37,8 @@ import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { ToastService } from './toast.service'; import type { DialogsService } from './dialogs.service'; import type { DataService } from './data.service'; -import type { AmmoEquipment, WeaponType } from '../models/equipment.model'; +import type { AmmoEquipment } from '../models/equipment.model'; +import type { WeaponType } from '../models/weapon-types.model'; import type { InventoryControlDisplayData, InventoryControlDisplayEffectOptions, InventoryControlRules } from '../utils/inventory-control.util'; import type { WeaponDamage } from '../models/equipment.model'; import type { InventoryControlDamageContext } from '../utils/inventory-control-damage.util'; diff --git a/src/app/services/unit-search-filters.model.ts b/src/app/services/unit-search-filters.model.ts index bc8979e0b..b36854e3a 100644 --- a/src/app/services/unit-search-filters.model.ts +++ b/src/app/services/unit-search-filters.model.ts @@ -40,6 +40,7 @@ import { MEGAMEK_AVAILABILITY_ALL_RARITY_OPTIONS, MEGAMEK_AVAILABILITY_FROM_FILTER_OPTIONS, } from '../models/megamek/availability.model'; +import { normalizeWeaponType, WEAPON_TYPES, WEAPON_TYPE_DISPLAY_NAMES } from '../models/weapon-types.model'; import { CBT_WEIGHT_CLASSES } from '../models/units.model'; import type { SemanticFilterState } from '../utils/semantic-filter.util'; @@ -65,7 +66,7 @@ export type MegaMekRaritySortKey = export type DropdownOptionSource = 'indexed' | 'external' | 'context'; export type DropdownAvailabilitySource = 'indexed' | 'context'; -export type DropdownPropertyShape = 'scalar' | 'array' | 'component'; +export type DropdownPropertyShape = 'scalar' | 'array' | 'component' | 'countable'; export type BooleanFilterSource = 'boolean' | 'nonEmptyArray' | 'truthy'; export type TriStateBooleanFilterValue = null | 'or' | 'not'; @@ -452,6 +453,7 @@ export const DROPDOWN_FILTERS: readonly DropdownFilterConfig[] = Object.freeze([ { key: 'as._motive', semanticKey: 'motive', label: 'Motive', game: GameSystem.ALPHA_STRIKE, sortOptions: Object.values(AS_MOVEMENT_MODE_DISPLAY_NAMES), optionSource: 'indexed', availabilitySource: 'indexed', propertyShape: 'array', valueNormalizer: normalizeMotiveValue }, { key: 'as.specials', semanticKey: 'specials', label: 'Specials', multistate: true, game: GameSystem.ALPHA_STRIKE, optionSource: 'indexed', availabilitySource: 'indexed', propertyShape: 'array' }, { key: 'componentName', semanticKey: 'equipment', label: 'Equipment', multistate: true, countable: true, game: GameSystem.CLASSIC, optionSource: 'indexed', availabilitySource: 'context', propertyShape: 'component' }, + { key: 'weaponType', semanticKey: 'weaponType', label: 'Weapon Type', multistate: true, countable: true, game: GameSystem.CLASSIC, sortOptions: [...WEAPON_TYPES], optionSource: 'indexed', availabilitySource: 'context', propertyShape: 'countable', valueNormalizer: normalizeWeaponType, displayNameFn: value => WEAPON_TYPE_DISPLAY_NAMES[value as keyof typeof WEAPON_TYPE_DISPLAY_NAMES] ?? value }, { key: 'features', semanticKey: 'features', label: 'Features', multistate: true, game: GameSystem.CLASSIC, optionSource: 'indexed', availabilitySource: 'indexed', propertyShape: 'array' }, { key: 'quirks', semanticKey: 'quirks', label: 'Quirks', multistate: true, game: GameSystem.CLASSIC, optionSource: 'indexed', availabilitySource: 'indexed', propertyShape: 'array' }, { key: 'source', semanticKey: 'source', label: 'Source', multistate: true, optionSource: 'indexed', availabilitySource: 'indexed', propertyShape: 'array' }, @@ -553,7 +555,7 @@ export const SORT_OPTIONS: SortOption[] = [ { key: 'name', label: 'Name' }, ...ADVANCED_FILTERS .filter(f => f.type !== AdvFilterType.BOOLEAN) - .filter(f => !['era', 'faction', 'availabilityRarity', 'availabilityFrom', 'forcePack', 'componentName', 'source', '_tags', 'as.specials', 'name', 'chassis', 'model', 'as._motive', 'quirks', 'features'].includes(f.key)) + .filter(f => !['era', 'faction', 'availabilityRarity', 'availabilityFrom', 'forcePack', 'componentName', 'weaponType', 'source', '_tags', 'as.specials', 'name', 'chassis', 'model', 'as._motive', 'quirks', 'features'].includes(f.key)) .map(f => ({ key: f.key, label: f.label, diff --git a/src/app/services/unit-search-filters.service.ts b/src/app/services/unit-search-filters.service.ts index 710015d67..50bad80d7 100644 --- a/src/app/services/unit-search-filters.service.ts +++ b/src/app/services/unit-search-filters.service.ts @@ -77,7 +77,7 @@ import { import { getProperty, getSelectedPositiveDropdownNames, - getUnitComponentData, + getUnitCountableFilterData, measureStage, normalizeMultiStateSelection, } from '../utils/unit-search-shared.util'; @@ -2678,14 +2678,14 @@ export class UnitSearchFiltersService { filterKey: string, optionNames: readonly string[], contextUnitIds: ReadonlySet, - isComponentFilter: boolean, + isCountableFilter: boolean, ): Set { const availableNames = new Set(); for (const optionName of optionNames) { const indexedIds = this.dataService.getIndexedUnitIds(filterKey, optionName); if (indexedIds && setHasAny(indexedIds, contextUnitIds)) { - availableNames.add(isComponentFilter ? optionName.toLowerCase() : optionName); + availableNames.add(isCountableFilter ? optionName.toLowerCase() : optionName); } } @@ -2696,7 +2696,7 @@ export class UnitSearchFiltersService { filterKey: string, units: Unit[], selection: MultiStateSelection, - isComponentFilter: boolean, + isCountableFilter: boolean, ): Set | null { const andEntries = Object.entries(selection).filter(([, sel]) => sel.state === 'and'); if (andEntries.length === 0) { @@ -2714,7 +2714,7 @@ export class UnitSearchFiltersService { ); const availableNames = new Set(); - if (!isComponentFilter) { + if (!isCountableFilter) { const universeNames = this.getIndexedUniverseNames(filterKey); if (universeNames.length > 0) { const contextUnitIds = new Set(units.map(unit => unit.name)); @@ -2779,8 +2779,11 @@ export class UnitSearchFiltersService { } for (const unit of units) { - if (isComponentFilter) { - const cached = getUnitComponentData(unit); + if (isCountableFilter) { + const cached = getUnitCountableFilterData(unit, filterKey); + if (!cached) { + continue; + } let excluded = false; for (const notName of notSet) { diff --git a/src/app/services/unit-search-index.service.spec.ts b/src/app/services/unit-search-index.service.spec.ts index 7b317ed23..73d3d724a 100644 --- a/src/app/services/unit-search-index.service.spec.ts +++ b/src/app/services/unit-search-index.service.spec.ts @@ -1,4 +1,5 @@ import type { Unit } from '../models/units.model'; +import { Equipment, WeaponEquipment } from '../models/equipment.model'; import { createEmptyUnit, type TestUnitOverrides } from '../testing/unit-test-helpers'; import { UnitSearchIndexService } from './unit-search-index.service'; @@ -241,4 +242,72 @@ describe('UnitSearchIndexService', () => { expect(service.getIndexedUnitIds('published', 'yes')).toEqual(new Set(['Canon Published'])); expect(service.getIndexedUnitIds('published', 'no')).toEqual(new Set(['Non-Canon Unpublished'])); }); + + it('indexes mounted quantities for every intrinsic weapon type', () => { + const service = new UnitSearchIndexService(); + const areaEffectAntiInfantryWeapon = new WeaponEquipment({ + id: 'test-vgl', + name: 'Test VGL', + type: 'weapon', + flags: ['F_VGL', 'F_MG'], + }); + const unit = createUnit({ + name: 'Typed Unit', + comp: [ + { id: 'test-vgl', q: 2, n: 'Test VGL', t: 'B', p: 1, l: 'RA', eq: areaEffectAntiInfantryWeapon }, + { id: 'test-vgl', q: 1, n: 'Test VGL', t: 'B', p: 2, l: 'LA', eq: areaEffectAntiInfantryWeapon }, + ], + }); + + service.rebuildIndexes([unit], [], []); + + expect(unit._weaponTypes).toEqual(['AE', 'AI', 'DB']); + expect(unit._weaponTypeCounts).toEqual({ AE: 3, AI: 3, DB: 3 }); + expect(service.getIndexedFilterValues('weaponType')).toEqual(['AE', 'AI', 'DB']); + expect(service.getIndexedUnitIds('weaponType', 'AI')).toEqual(new Set(['Typed Unit'])); + expect(service.getDropdownOptionUniverse('weaponType')).toEqual([{ name: 'AE' }, { name: 'AI' }, { name: 'DB' }]); + }); + + it('counts bay weapons without counting wrappers and ignores non-weapons', () => { + const service = new UnitSearchIndexService(); + const antiInfantryWeapon = new WeaponEquipment({ + id: 'test-mg', + name: 'Test MG', + type: 'weapon', + flags: ['F_MG'], + }); + const nonWeapon = new Equipment({ id: 'test-case', name: 'Test CASE', type: 'misc' }); + const unit = createUnit({ + name: 'Bay Unit', + comp: [ + { + id: 'weapon-bay', q: 10, n: 'Weapon Bay', t: 'B', p: 1, l: 'N', eq: antiInfantryWeapon, + bay: [{ id: 'test-mg', q: 2, n: 'Test MG', t: 'B', p: 1, l: 'N', eq: antiInfantryWeapon }], + }, + { id: 'test-case', q: 5, n: 'Test CASE', t: 'C', p: 2, l: 'CT', eq: nonWeapon }, + { id: 'unknown', q: 4, n: 'Unknown Weapon', t: 'B', p: 3, l: 'LT' }, + ], + }); + + service.rebuildIndexes([unit], [], []); + + expect(unit._weaponTypeCounts?.AI).toBe(2); + expect(unit._weaponTypeCounts?.DB).toBe(2); + }); + + it('replaces stale weapon-type data when indexes are rebuilt', () => { + const service = new UnitSearchIndexService(); + const unit = createUnit({ + name: 'Relinked Unit', + _weaponTypes: ['AI'], + _weaponTypeCounts: { AI: 4 }, + comp: [{ id: 'unknown', q: 4, n: 'Unknown Weapon', t: 'B', p: 1, l: 'RA' }], + }); + + service.rebuildIndexes([unit], [], []); + + expect(unit._weaponTypes).toEqual([]); + expect(unit._weaponTypeCounts).toEqual({}); + expect(service.getIndexedFilterValues('weaponType')).toEqual([]); + }); }); \ No newline at end of file diff --git a/src/app/services/unit-search-index.service.ts b/src/app/services/unit-search-index.service.ts index 1abad6d85..b78b3a335 100644 --- a/src/app/services/unit-search-index.service.ts +++ b/src/app/services/unit-search-index.service.ts @@ -44,6 +44,8 @@ import { parseASDamageValue } from '../utils/as-damage.util'; import { AS_MOVEMENT_MODE_DISPLAY_NAMES, BOOLEAN_FILTERS, getBooleanFilterUnitValue } from './unit-search-filters.model'; import type { UnitSearchWorkerFactionEraSnapshot, UnitSearchWorkerIndexSnapshot } from '../utils/unit-search-worker-protocol.util'; import { MULFACTION_EXTINCT } from '../models/mulfactions.model'; +import { WeaponEquipment } from '../models/equipment.model'; +import { WEAPON_TYPES, type WeaponType } from '../models/weapon-types.model'; interface ASUnitTypeMaxStats { [asUnitType: string]: MinMaxStatsRange; @@ -327,6 +329,8 @@ export class UnitSearchIndexService { this.addSearchIndexValues('source', getUnitSourceFilterValues(unit), unit.name); this.addSearchIndexValues('componentName', unit.comp.map(component => component.n), unit.name); this.addComponentCountValues(unit); + this.prepareUnitWeaponTypes(unit); + this.addSearchIndexValues('weaponType', unit._weaponTypes ?? [], unit.name); this.addSearchIndexValues('features', unit.features ?? [], unit.name); this.addSearchIndexValues('quirks', unit.quirks ?? [], unit.name); this.addSearchIndexValues('_tags', getMergedTags(unit), unit.name); @@ -455,6 +459,7 @@ export class UnitSearchIndexService { 'as._motive', 'source', 'componentName', + 'weaponType', 'features', 'quirks', '_tags', @@ -549,6 +554,31 @@ export class UnitSearchIndexService { } } + private prepareUnitWeaponTypes(unit: Unit): void { + const counts: Partial> = {}; + + const addComponents = (components: readonly UnitComponent[]): void => { + for (const component of components) { + if (component.bay?.length) { + addComponents(component.bay); + continue; + } + + if (!(component.eq instanceof WeaponEquipment) || !Number.isFinite(component.q) || component.q <= 0) { + continue; + } + + for (const weaponType of component.eq.getWeaponTypes()) { + counts[weaponType] = (counts[weaponType] ?? 0) + component.q; + } + } + }; + + addComponents(unit.comp); + unit._weaponTypeCounts = counts; + unit._weaponTypes = WEAPON_TYPES.filter(weaponType => (counts[weaponType] ?? 0) > 0); + } + private getASMotiveDisplayNames(unit: Unit): string[] { const movementModes = unit.as?.MVm; if (!movementModes) { diff --git a/src/app/utils/inventory-control-damage.util.ts b/src/app/utils/inventory-control-damage.util.ts index 56a35636e..8629214f5 100644 --- a/src/app/utils/inventory-control-damage.util.ts +++ b/src/app/utils/inventory-control-damage.util.ts @@ -2,11 +2,10 @@ import { AmmoEquipment, resolveWeaponAmmo, resolveWeaponDamage, - WEAPON_TYPES, WeaponDamage, WeaponEquipment, - type WeaponType, } from '../models/equipment.model'; +import { WEAPON_TYPES, type WeaponType } from '../models/weapon-types.model'; import type { EquipmentRegistry } from '../models/equipment-lookup'; import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import type { AmmoWeaponProfile } from '../models/ammo-weapon-profile.model'; diff --git a/src/app/utils/inventory-control.util.ts b/src/app/utils/inventory-control.util.ts index ca7bee8f0..e994a37a8 100644 --- a/src/app/utils/inventory-control.util.ts +++ b/src/app/utils/inventory-control.util.ts @@ -31,7 +31,8 @@ * affiliated with Microsoft. */ -import { AmmoEquipment, WeaponEquipment, type WeaponType } from '../models/equipment.model'; +import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; +import type { WeaponType } from '../models/weapon-types.model'; import type { EquipmentRegistry } from '../models/equipment-lookup'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; import { MountedAmmo, MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; diff --git a/src/app/utils/semantic-filter-ast.util.ts b/src/app/utils/semantic-filter-ast.util.ts index 731ef33d0..d7e83fc92 100644 --- a/src/app/utils/semantic-filter-ast.util.ts +++ b/src/app/utils/semantic-filter-ast.util.ts @@ -1198,7 +1198,7 @@ const parsedRangeValuesCache = new WeakMap(); const parsedSpecialQueryCache = new Map(); for (const filterConfig of ADVANCED_FILTERS) { - const semanticKey = filterConfig.semanticKey || filterConfig.key; + const semanticKey = (filterConfig.semanticKey || filterConfig.key).toLowerCase(); const matchingConfigs = FILTER_CONFIGS_BY_SEMANTIC_KEY.get(semanticKey); if (matchingConfigs) { matchingConfigs.push(filterConfig); @@ -1208,6 +1208,7 @@ for (const filterConfig of ADVANCED_FILTERS) { } function getSortedFilterConfigs(context: EvaluatorContext, semanticKey: string): readonly AdvFilterConfig[] { + semanticKey = semanticKey.toLowerCase(); let contextCache = sortedFilterConfigsCache.get(context); if (!contextCache) { contextCache = new Map(); @@ -1971,7 +1972,7 @@ function getIndexedCandidateIdsForFilter( activeScope?: AvailabilityFilterScope, ): Set | null { const matchingFilters = ADVANCED_FILTERS.filter(f => - (f.semanticKey || f.key) === filter.field + (f.semanticKey || f.key).toLowerCase() === filter.field.toLowerCase() ); if (matchingFilters.length === 0) { return null; diff --git a/src/app/utils/semantic-filter.util.ts b/src/app/utils/semantic-filter.util.ts index cbeb799f4..96eb668d7 100644 --- a/src/app/utils/semantic-filter.util.ts +++ b/src/app/utils/semantic-filter.util.ts @@ -163,7 +163,7 @@ export function buildSemanticKeyMap(gameSystem: GameSystem): Map1") - const { name, constraint } = parseValueWithQuantity(val); + const { name: rawName, constraint } = parseValueWithQuantity(val); + const name = normalizeValue(rawName); // Get or create constraint entry for this name let entry = countableConstraints.get(name); diff --git a/src/app/utils/unit-filter-kernel.util.ts b/src/app/utils/unit-filter-kernel.util.ts index be253209f..856f274f9 100644 --- a/src/app/utils/unit-filter-kernel.util.ts +++ b/src/app/utils/unit-filter-kernel.util.ts @@ -48,10 +48,11 @@ import { wildcardToRegex } from './string.util'; import { checkQuantityConstraint, getSelectedPositiveDropdownNames, - getUnitComponentData, + getUnitCountableFilterData, normalizeMultiStateSelection, } from './unit-search-shared.util'; import { getUnitVariantGroupKey } from './unit-variant.util'; +import { isCountableBackedDropdown } from './unit-search-filter-config.util'; export interface UnitFilterKernelDependencies { getProperty: (unit: Unit, key?: string) => unknown; @@ -107,7 +108,7 @@ function filterUnitsByMultiState( item.countIncludeRanges || item.countExcludeRanges; const needsQuantityCounting = orList.some(hasQuantityConstraint) || andList.some(hasQuantityConstraint) || notList.some(hasQuantityConstraint); - const isComponentFilter = key === 'componentName'; + const isCountableFilter = isCountableBackedDropdown(ADVANCED_FILTER_CONFIG_BY_KEY.get(key)); const compiledOrPatterns = wildcardPatterns?.filter(p => p.state === 'or').map(pattern => ({ pattern, regex: wildcardToRegex(pattern.pattern) })) ?? []; const compiledAndPatterns = wildcardPatterns?.filter(p => p.state === 'and').map(pattern => ({ pattern, regex: wildcardToRegex(pattern.pattern) })) ?? []; const compiledNotPatterns = wildcardPatterns?.filter(p => p.state === 'not').map(pattern => ({ pattern, regex: wildcardToRegex(pattern.pattern) })) ?? []; @@ -115,11 +116,11 @@ function filterUnitsByMultiState( return units.filter(unit => { let unitData: { names: Set; counts?: Map }; - if (isComponentFilter) { - const cached = getUnitComponentData(unit); + if (isCountableFilter) { + const cached = getUnitCountableFilterData(unit, key); unitData = { - names: cached.names, - counts: needsQuantityCounting ? cached.counts : undefined, + names: cached?.names ?? new Set(), + counts: needsQuantityCounting ? cached?.counts : undefined, }; } else { const propValue = getProperty(unit, key); diff --git a/src/app/utils/unit-search-adv-options-builder.util.spec.ts b/src/app/utils/unit-search-adv-options-builder.util.spec.ts index f118d119d..f39d15aa9 100644 --- a/src/app/utils/unit-search-adv-options-builder.util.spec.ts +++ b/src/app/utils/unit-search-adv-options-builder.util.spec.ts @@ -32,10 +32,70 @@ */ import { GameSystem } from '../models/common.model'; +import { createEmptyUnit } from '../testing/unit-test-helpers'; import { ADVANCED_FILTERS } from '../services/unit-search-filters.model'; import { buildUnitSearchAdvOptions } from './unit-search-adv-options-builder.util'; describe('buildUnitSearchAdvOptions', () => { + it('marks canonical weapon types available from derived unit fields', () => { + const weaponTypeFilter = ADVANCED_FILTERS.find(filter => filter.key === 'weaponType'); + expect(weaponTypeFilter).toBeDefined(); + + const unit = createEmptyUnit({ + name: 'Anti-Infantry Unit', + _weaponTypes: ['AI'], + _weaponTypeCounts: { AI: 2 }, + }); + const result = buildUnitSearchAdvOptions({ + advancedFilters: [weaponTypeFilter!], + state: { + weaponType: { + value: { + AI: { name: 'AI', state: 'or', count: 1, countOperator: '>=' }, + }, + interactedWith: true, + }, + }, + units: [unit], + queryText: '', + textSearch: '', + isComplexQuery: false, + totalRanges: {}, + dynamicInternalLabel: 'Internal', + gameSystem: GameSystem.CLASSIC, + getUnitFilterKernelDependencies: () => ({ + getProperty: () => undefined, + getAdjustedBV: () => 0, + getAdjustedPV: () => 0, + getUnitIdsForExternalFilters: () => null, + getPositiveFactionNames: () => [], + unitMatchesAvailabilityFrom: () => false, + unitMatchesAvailabilityRarity: () => false, + getForcePackLookupSet: () => undefined, + getAvailabilityLookupKey: () => '', + }), + buildIndexedDropdownOptions: () => [ + { name: 'AI', displayName: 'Anti-Infantry', available: true }, + ], + buildForcePackDropdownOptions: () => [], + getIndexedUniverseNames: () => ['AI'], + getSortedIndexedUniverseNames: () => ['AI'], + collectIndexedAvailabilityNames: () => new Set(), + collectConstrainedMultistateAvailabilityNames: () => null, + getAvailableRangeForUnits: () => [0, 0], + getDisplayName: () => 'Anti-Infantry', + }); + + expect(result.options['weaponType'].options).toEqual([ + jasmine.objectContaining({ + name: 'AI', + displayName: 'Anti-Infantry', + available: true, + count: 2, + }), + ]); + }); + it('keeps wildcard-only multistate semantic filters visible in dropdown display items', () => { const factionFilter = ADVANCED_FILTERS.find(filter => filter.key === 'faction'); expect(factionFilter).toBeDefined(); diff --git a/src/app/utils/unit-search-adv-options-builder.util.ts b/src/app/utils/unit-search-adv-options-builder.util.ts index 68982f173..70d4b598c 100644 --- a/src/app/utils/unit-search-adv-options-builder.util.ts +++ b/src/app/utils/unit-search-adv-options-builder.util.ts @@ -35,11 +35,11 @@ import type { MultiStateSelection } from '../components/multi-select-dropdown/mu import type { GameSystem } from '../models/common.model'; import type { Unit } from '../models/units.model'; import type { WildcardPattern } from './semantic-filter.util'; -import { getAdvOptionsContextSnapshot, getSnapshotAvailabilityNames, getSnapshotAvailableNames, getSnapshotComponentCounts, getSnapshotUnitIds, type AdvOptionsContextSnapshot } from './unit-search-adv-options.util'; +import { getAdvOptionsContextSnapshot, getSnapshotAvailabilityNames, getSnapshotAvailableNames, getSnapshotCountableValues, getSnapshotUnitIds, type AdvOptionsContextSnapshot } from './unit-search-adv-options.util'; import { applyFilterStateToUnits, type UnitFilterKernelDependencies } from './unit-filter-kernel.util'; import { matchesSearch, parseSearchQuery } from './search.util'; import { getNowMs, getProperty, normalizeMultiStateSelection } from './unit-search-shared.util'; -import { isComponentBackedDropdown, usesIndexedDropdownAvailability, usesIndexedDropdownUniverse } from './unit-search-filter-config.util'; +import { isComponentBackedDropdown, isCountableBackedDropdown, usesIndexedDropdownAvailability, usesIndexedDropdownUniverse } from './unit-search-filter-config.util'; import { sortAvailableDropdownOptions, sortDropdownOptionObjects } from './unit-search-dropdown-sort.util'; import { AdvFilterType, normalizeTriStateBooleanFilterValue, type AdvFilterConfig, type AdvFilterOptions, type AdvOptionsTelemetryFilterStage, type AdvOptionsTelemetrySnapshot, type FilterState, type SemanticDisplayItem } from '../services/unit-search-filters.model'; @@ -363,12 +363,19 @@ export function buildUnitSearchAdvOptions(request: BuildUnitSearchAdvOptionsRequ availableOptions = request.buildIndexedDropdownOptions(conf, contextUnits, displayNameFn, contextUnitIds); } else if (conf.multistate) { const isComponentFilter = isComponentBackedDropdown(conf); + const isCountableFilter = isCountableBackedDropdown(conf); const currentFilter = request.state[conf.key]; const normalizedCurrentSelection = currentFilter?.interactedWith ? normalizeMultiStateSelection(currentFilter.value) : {}; - const hasQuantityFilters = conf.countable && isComponentFilter - && Object.values(normalizedCurrentSelection).some(selection => selection.count > 1); + const hasQuantityFilters = conf.countable && isCountableFilter + && Object.values(normalizedCurrentSelection).some(selection => + selection.count > 1 + || selection.countOperator !== undefined + || selection.countMax !== undefined + || selection.countIncludeRanges !== undefined + || selection.countExcludeRanges !== undefined + ); const indexedUniverse = usesIndexedDropdownUniverse(conf); const availableNames = indexedUniverse ? request.getIndexedUniverseNames(conf.key) @@ -378,7 +385,7 @@ export function buildUnitSearchAdvOptions(request: BuildUnitSearchAdvOptionsRequ conf.key, contextUnits, normalizedCurrentSelection, - isComponentFilter, + isCountableFilter, ) : null; @@ -388,7 +395,7 @@ export function buildUnitSearchAdvOptions(request: BuildUnitSearchAdvOptionsRequ const availableNameSet = constrainedAvailableNameSet ?? (indexedUniverse ? (usesIndexedDropdownAvailability(conf) - ? request.collectIndexedAvailabilityNames(conf.key, sortedNames, contextUnitIds, isComponentFilter) + ? request.collectIndexedAvailabilityNames(conf.key, sortedNames, contextUnitIds, isCountableFilter) : getSnapshotAvailabilityNames(contextSnapshot, conf.key, contextUnits, isComponentFilter)) : getSnapshotAvailabilityNames(contextSnapshot, conf.key, contextUnits, isComponentFilter)); const indexedOptionMetadata = indexedUniverse @@ -400,17 +407,17 @@ export function buildUnitSearchAdvOptions(request: BuildUnitSearchAdvOptionsRequ let totalCountsMap: Map | null = null; if (hasQuantityFilters) { - totalCountsMap = getSnapshotComponentCounts(contextSnapshot, contextUnits); + totalCountsMap = getSnapshotCountableValues(contextSnapshot, conf.key, contextUnits); } const optionsWithAvailability = sortedNames.map(name => { - const normalizedName = isComponentFilter ? name.toLowerCase() : name; + const normalizedName = isCountableFilter ? name.toLowerCase() : name; const metadata = indexedOptionMetadata?.get(name); const option: { name: string; img?: string; displayName?: string; available: boolean; count?: number } = { name, ...(metadata?.img ? { img: metadata.img } : {}), ...(metadata?.displayName ? { displayName: metadata.displayName } : {}), - available: availableNameSet.has(normalizedName), + available: availableNameSet.has(normalizedName) || availableNameSet.has(name), }; if (totalCountsMap) { diff --git a/src/app/utils/unit-search-adv-options.util.ts b/src/app/utils/unit-search-adv-options.util.ts index e999ef9cc..775e2432b 100644 --- a/src/app/utils/unit-search-adv-options.util.ts +++ b/src/app/utils/unit-search-adv-options.util.ts @@ -32,14 +32,14 @@ */ import type { Unit } from '../models/units.model'; -import { getProperty, getUnitComponentData } from './unit-search-shared.util'; +import { getProperty, getUnitComponentData, getUnitCountableFilterData } from './unit-search-shared.util'; export interface AdvOptionsContextSnapshot { unitIds?: Set; forcePackNames?: Set; namesByFilterKey: Map; availabilityNamesByFilterKey: Map>; - componentCounts?: Map; + countsByFilterKey?: Map>; } export function getAdvOptionsContextSnapshot( @@ -51,6 +51,7 @@ export function getAdvOptionsContextSnapshot( snapshot = { namesByFilterKey: new Map(), availabilityNamesByFilterKey: new Map>(), + countsByFilterKey: new Map>(), }; cache.set(units, snapshot); } @@ -150,19 +151,25 @@ export function getSnapshotAvailabilityNames( return snapshot.availabilityNamesByFilterKey.get(filterKey) ?? new Set(); } -export function getSnapshotComponentCounts(snapshot: AdvOptionsContextSnapshot, units: Unit[]): Map { - if (!snapshot.componentCounts) { - const counts = new Map(); +export function getSnapshotCountableValues( + snapshot: AdvOptionsContextSnapshot, + filterKey: string, + units: Unit[], +): Map { + snapshot.countsByFilterKey ??= new Map>(); + let counts = snapshot.countsByFilterKey.get(filterKey); + if (!counts) { + counts = new Map(); for (const unit of units) { - const cached = getUnitComponentData(unit); - for (const [name, count] of cached.counts) { + const data = getUnitCountableFilterData(unit, filterKey); + for (const [name, count] of data?.counts ?? []) { counts.set(name, (counts.get(name) || 0) + count); } } - snapshot.componentCounts = counts; + snapshot.countsByFilterKey.set(filterKey, counts); } - return snapshot.componentCounts; + return counts; } \ No newline at end of file diff --git a/src/app/utils/unit-search-executor.util.spec.ts b/src/app/utils/unit-search-executor.util.spec.ts index 8e87260d7..6a5d14365 100644 --- a/src/app/utils/unit-search-executor.util.spec.ts +++ b/src/app/utils/unit-search-executor.util.spec.ts @@ -28,6 +28,26 @@ function executeSortedUnits(units: Unit[], sortKey: string): Unit[] { }).results; } +function executeQuery(units: Unit[], query: string): Unit[] { + return executeUnitSearch({ + units, + parsedQuery: parseSemanticQueryAST(query, GameSystem.CLASSIC), + searchTokens: [], + gameSystem: GameSystem.CLASSIC, + sortKey: 'name', + sortDirection: 'asc', + bvPvLimit: 0, + forceTotalBvPv: 0, + getAdjustedBV: unit => unit.bv, + getAdjustedPV: unit => unit.as.PV, + unitBelongsToEra: () => false, + unitBelongsToFaction: () => false, + unitBelongsToForcePack: () => false, + getAllEraNames: () => [], + getAllFactionNames: () => [], + }).results; +} + describe('unit-search-executor', () => { it('uses unit name order as the tie-breaker for equal sort option values', () => { const locust10 = createUnit({ name: 'Locust IIC 10', chassis: 'Locust IIC', model: '10', tons: 25 }); @@ -38,4 +58,33 @@ describe('unit-search-executor', () => { expect(sortedNames).toEqual(['Locust IIC 2', 'Locust IIC 10', 'Atlas AS7-D']); }); + + it('filters plain worker-safe weapon-type counts by minimum quantity', () => { + const oneAI = createEmptyUnit({ name: 'One AI', _weaponTypes: ['AI'], _weaponTypeCounts: { AI: 1 } }); + const twoAI = createEmptyUnit({ name: 'Two AI', _weaponTypes: ['AI'], _weaponTypeCounts: { AI: 2 } }); + const noAI = createEmptyUnit({ name: 'No AI' }); + + expect(executeQuery([oneAI, twoAI, noAI], 'weaponType="AI:>=2"').map(unit => unit.name)) + .toEqual(['Two AI']); + expect(executeQuery([oneAI, twoAI, noAI], 'WEAPONTYPE=AP').map(unit => unit.name)) + .toEqual(['One AI', 'Two AI']); + }); + + it('evaluates selected weapon types independently for OR and AND queries', () => { + const dualTyped = createEmptyUnit({ + name: 'Dual Typed', + _weaponTypes: ['AE', 'AI'], + _weaponTypeCounts: { AE: 2, AI: 2 }, + }); + const areaEffectOnly = createEmptyUnit({ + name: 'Area Effect Only', + _weaponTypes: ['AE'], + _weaponTypeCounts: { AE: 2 }, + }); + + expect(executeQuery([dualTyped, areaEffectOnly], 'weaponType="AI:>=2","AE:>=2"').map(unit => unit.name)) + .toEqual(['Dual Typed', 'Area Effect Only']); + expect(executeQuery([dualTyped, areaEffectOnly], 'weaponType&="AI:>=2" weaponType&="AE:>=2"').map(unit => unit.name)) + .toEqual(['Dual Typed']); + }); }); \ No newline at end of file diff --git a/src/app/utils/unit-search-executor.util.ts b/src/app/utils/unit-search-executor.util.ts index bb2ccac35..e321f0660 100644 --- a/src/app/utils/unit-search-executor.util.ts +++ b/src/app/utils/unit-search-executor.util.ts @@ -45,7 +45,7 @@ import { import { matchesSearch, parseSearchQuery, type SearchTokensGroup } from './search.util'; import { compareUnitsByName, computeRelevanceScore, naturalCompare } from './sort.util'; import { wildcardToRegex } from './string.util'; -import { getNowMs, getProperty, getUnitComponentData, isCommittedSemanticToken, measureStage } from './unit-search-shared.util'; +import { getNowMs, getProperty, getUnitCountableFilterData, isCommittedSemanticToken, measureStage } from './unit-search-shared.util'; import { applyFilterStateToUnits, type UnitFilterKernelDependencies } from './unit-filter-kernel.util'; import type { AvailabilityFilterScope } from '../services/unit-search-filters.model'; @@ -166,7 +166,8 @@ export function executeUnitSearch(request: UnitSearchExecutionRequest): UnitSear getCountableValues: (unit: Unit, filterKey: string) => { switch (filterKey) { case 'componentName': - return getUnitComponentData(unit).counts; + case 'weaponType': + return getUnitCountableFilterData(unit, filterKey)?.counts ?? null; default: return null; } diff --git a/src/app/utils/unit-search-filter-config.util.ts b/src/app/utils/unit-search-filter-config.util.ts index 50ade4e33..15630fe1b 100644 --- a/src/app/utils/unit-search-filter-config.util.ts +++ b/src/app/utils/unit-search-filter-config.util.ts @@ -47,7 +47,7 @@ const advancedFilterConfigBySemanticField = new Map(); for (const config of ADVANCED_FILTERS) { advancedFilterConfigByKey.set(config.key, config); - const semanticField = config.semanticKey || config.key; + const semanticField = (config.semanticKey || config.key).toLowerCase(); if (!advancedFilterConfigBySemanticField.has(semanticField)) { advancedFilterConfigBySemanticField.set(semanticField, config); } @@ -58,7 +58,7 @@ export function getAdvancedFilterConfigByKey(key: string): AdvFilterConfig | und } export function getAdvancedFilterConfigBySemanticField(field: string): AdvFilterConfig | undefined { - return advancedFilterConfigBySemanticField.get(field); + return advancedFilterConfigBySemanticField.get(field.toLowerCase()); } export function isFilterAvailableForAvailabilitySource( @@ -114,13 +114,18 @@ export function usesIndexedDropdownAvailability(config: AdvFilterConfig | undefi export function isArrayBackedDropdown(config: AdvFilterConfig | undefined): boolean { const shape = getDropdownPropertyShape(config); - return shape === 'array' || shape === 'component'; + return shape === 'array' || shape === 'component' || shape === 'countable'; } export function isComponentBackedDropdown(config: AdvFilterConfig | undefined): boolean { return getDropdownPropertyShape(config) === 'component'; } +export function isCountableBackedDropdown(config: AdvFilterConfig | undefined): boolean { + const shape = getDropdownPropertyShape(config); + return shape === 'component' || shape === 'countable'; +} + export function getDropdownCapabilityMetadataErrors(configs: readonly AdvFilterConfig[] = ADVANCED_FILTERS): string[] { const errors: string[] = []; diff --git a/src/app/utils/unit-search-shared.util.ts b/src/app/utils/unit-search-shared.util.ts index 7ea12af47..3db18ee59 100644 --- a/src/app/utils/unit-search-shared.util.ts +++ b/src/app/utils/unit-search-shared.util.ts @@ -103,6 +103,9 @@ export function getProperty(obj: any, key?: string) { if (key === 'source') { return getUnitSourceFilterValues(obj as Unit); } + if (key === 'weaponType') { + return (obj as Unit)._weaponTypes ?? []; + } if (key === 'as._motive') { const mvm = (obj as Unit).as?.MVm; if (!mvm) return []; @@ -283,6 +286,31 @@ export function getUnitComponentData(unit: Unit): UnitComponentData { return cached; } +export function getUnitCountableFilterData(unit: Unit, filterKey: string): UnitComponentData | null { + if (filterKey === 'componentName') { + return getUnitComponentData(unit); + } + + if (filterKey !== 'weaponType') { + return null; + } + + const names = new Set(); + const counts = new Map(); + + for (const [weaponType, count] of Object.entries(unit._weaponTypeCounts ?? {})) { + if (typeof count !== 'number' || count <= 0) { + continue; + } + + const normalizedWeaponType = weaponType.toLowerCase(); + names.add(normalizedWeaponType); + counts.set(normalizedWeaponType, count); + } + + return { names, counts }; +} + export function checkQuantityConstraint( unitCount: number, count: number, diff --git a/src/app/utils/unit-search-url-filters.util.ts b/src/app/utils/unit-search-url-filters.util.ts index e4a9832e8..acf342697 100644 --- a/src/app/utils/unit-search-url-filters.util.ts +++ b/src/app/utils/unit-search-url-filters.util.ts @@ -315,6 +315,8 @@ function parseCompactFiltersFromUrl( name = name.slice(0, -1); } + name = conf.valueNormalizer?.(name) ?? name; + selection[name] = { name, state, count }; } @@ -364,7 +366,8 @@ function validateParsedFiltersFromUrl( const selection = normalizeMultiStateSelection(state.value); const validSelection: MultiStateSelection = {}; for (const [name, selectionValue] of Object.entries(selection)) { - const properCase = availableValuesMap.get(name.toLowerCase()); + const normalizedName = conf.valueNormalizer?.(name) ?? name; + const properCase = availableValuesMap.get(normalizedName.toLowerCase()); if (properCase) { validSelection[properCase] = { ...selectionValue, name: properCase }; } diff --git a/src/app/utils/unit-search-worker-request.util.spec.ts b/src/app/utils/unit-search-worker-request.util.spec.ts index a7b00a3a3..0c080b56f 100644 --- a/src/app/utils/unit-search-worker-request.util.spec.ts +++ b/src/app/utils/unit-search-worker-request.util.spec.ts @@ -72,6 +72,27 @@ describe('buildWorkerExecutionQuery', () => { }), ]); }); + + it('serializes weapon-type minimum quantities for worker execution', () => { + const executionQuery = buildWorkerExecutionQuery({ + effectiveFilterState: { + weaponType: { + value: { + AI: { name: 'AI', state: 'or', count: 2 }, + AE: { name: 'AE', state: 'and', count: 1 }, + }, + interactedWith: true, + }, + }, + effectiveTextSearch: '', + gameSystem: GameSystem.CLASSIC, + totalRangesCache: {}, + }); + + expect(executionQuery).toContain('weaponType="AI:>=2"'); + expect(executionQuery).toContain('weaponType&=AE'); + expect(parseSemanticQueryAST(executionQuery, GameSystem.CLASSIC).errors).toEqual([]); + }); }); describe('getWorkerCorpusSnapshot', () => {