Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/app/components/semantic-guide/semantic-guide.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,45 @@ <h3>Quantity Constraints</h3>
<div class="example"><code class="allow-select" (click)="appendToSearch('equipment=PPC:2-4')">equipment=PPC:2-4</code><span class="desc">→ Between 2 and 4 PPCs</span></div>
<div class="example"><code class="allow-select" (click)="appendToSearch('equipment=PPC:!3')">equipment=PPC:!3</code><span class="desc">→ Not exactly 3 PPCs</span></div>
<div class="example"><code class="allow-select" (click)="appendToSearch('equipment&=AC5:>2,CASE')">equipment&=AC5:>2,CASE</code><span class="desc">→ More than 2 AC5s and has CASE (must have both)</span></div>
<div class="example"><code class="allow-select" (click)="appendToSearch('weaponType=AI:>=2')">weaponType=AI:&gt;=2</code><span class="desc">→ At least 2 anti-infantry weapons</span></div>
<div class="example"><code class="allow-select" (click)="appendToSearch('weaponType&=M:>=2,F')">weaponType&amp;=M:&gt;=2,F</code><span class="desc">→ At least 2 missile weapons and at least 1 flak weapon</span></div>
</div>
</section>
}

<!-- Classic BattleTech Weapon Types -->
<section class="guide-section">
<h3>Classic BattleTech Weapon Types</h3>
<p><code>weaponType</code> matches intrinsic weapon properties and supports OR, AND, NOT, and quantity constraints.</p>
<div class="examples">
<div class="example"><code class="allow-select" (click)="appendToSearch('weaponType=AI')">weaponType=AI</code><span class="desc">→ Has an anti-infantry weapon</span></div>
<div class="example"><code class="allow-select" (click)="appendToSearch('weaponType=DB,DE')">weaponType=DB,DE</code><span class="desc">→ Has a direct-fire ballistic OR energy weapon</span></div>
<div class="example"><code class="allow-select" (click)="appendToSearch('weaponType&=M,F')">weaponType&amp;=M,F</code><span class="desc">→ Has both missile and flak weapons</span></div>
<div class="example"><code class="allow-select" (click)="appendToSearch('weaponType!=OS')">weaponType!=OS</code><span class="desc">→ Excludes units with one-shot weapons</span></div>
</div>
<p class="note"><strong>Codes:</strong>
<code>A</code> Artillery,
<code>AE</code> Area-Effect,
<code>AI</code> Anti-Infantry,
<code>B</code> Ballistic,
<code>C</code> Cluster,
<code>DB</code> Direct-Fire Ballistic,
<code>DE</code> Direct-Fire Energy,
<code>E</code> Energy,
<code>F</code> Flak,
<code>H</code> Heat-Causing,
<code>M</code> Missile,
<code>N</code> Nuclear,
<code>OS</code> One-Shot,
<code>P</code> Pulse,
<code>PB</code> Point-Blank,
<code>R</code> Rapid-Fire,
<code>S</code> Switchable Ammo,
<code>V</code> Variable Damage,
<code>X</code> Explosive.
</p>
</section>

<!-- Alpha Strike Specials -->
<section class="guide-section">
<h3>Alpha Strike Specials</h3>
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down
2 changes: 1 addition & 1 deletion src/app/equipment-handlers/apollo.handler.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 2 additions & 1 deletion src/app/equipment-handlers/bombast-laser.handler.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 2 additions & 1 deletion src/app/equipment-handlers/bombast-laser.handler.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/app/equipment-handlers/hag.handler.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
3 changes: 2 additions & 1 deletion src/app/equipment-handlers/hag.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/app/equipment-handlers/ppc-capacitor.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 1 addition & 3 deletions src/app/models/equipment.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
// ============================================================================
Expand Down
3 changes: 2 additions & 1 deletion src/app/models/mounted-equipment.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/app/models/rules/unit-type-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions src/app/models/units.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Record<WeaponType, number>>; // 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)
Expand Down
18 changes: 18 additions & 0 deletions src/app/models/weapon-types.model.spec.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
63 changes: 63 additions & 0 deletions src/app/models/weapon-types.model.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*
* 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"
* <https://www.xbox.com/en-US/developers/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<Record<WeaponType, string>> = {
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;
}
3 changes: 2 additions & 1 deletion src/app/services/equipment-interaction-registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 4 additions & 2 deletions src/app/services/unit-search-filters.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';

Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 10 additions & 7 deletions src/app/services/unit-search-filters.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ import {
import {
getProperty,
getSelectedPositiveDropdownNames,
getUnitComponentData,
getUnitCountableFilterData,
measureStage,
normalizeMultiStateSelection,
} from '../utils/unit-search-shared.util';
Expand Down Expand Up @@ -2678,14 +2678,14 @@ export class UnitSearchFiltersService {
filterKey: string,
optionNames: readonly string[],
contextUnitIds: ReadonlySet<string>,
isComponentFilter: boolean,
isCountableFilter: boolean,
): Set<string> {
const availableNames = new Set<string>();

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);
}
}

Expand All @@ -2696,7 +2696,7 @@ export class UnitSearchFiltersService {
filterKey: string,
units: Unit[],
selection: MultiStateSelection,
isComponentFilter: boolean,
isCountableFilter: boolean,
): Set<string> | null {
const andEntries = Object.entries(selection).filter(([, sel]) => sel.state === 'and');
if (andEntries.length === 0) {
Expand All @@ -2714,7 +2714,7 @@ export class UnitSearchFiltersService {
);
const availableNames = new Set<string>();

if (!isComponentFilter) {
if (!isCountableFilter) {
const universeNames = this.getIndexedUniverseNames(filterKey);
if (universeNames.length > 0) {
const contextUnitIds = new Set(units.map(unit => unit.name));
Expand Down Expand Up @@ -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) {
Expand Down
Loading