From 124051706e8ca7075105c0b4626f79fa03cee6bc Mon Sep 17 00:00:00 2001 From: exeea Date: Fri, 7 Aug 2026 20:27:24 +0200 Subject: [PATCH 01/12] . --- src/app/services/account-protection.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/services/account-protection.service.ts b/src/app/services/account-protection.service.ts index 4aa39cc05..4e7660585 100644 --- a/src/app/services/account-protection.service.ts +++ b/src/app/services/account-protection.service.ts @@ -76,7 +76,7 @@ export class AccountProtectionService { disableClose: true, data: { title: 'Keep your MekBay data with you', - message: 'Link an OAuth provider to recover your data and access it easily on other devices. This is optional and can also be done later in Options.', + message: 'Link an OAuth provider to easily recover your data and access it on other devices. This is optional and can also be done later in Options.', providers, actionLabel: 'Link', dismissLabel: 'NO THANKS', From 9f695b1670a95abf56fa5a3ddc639fe1233c07fd Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 8 Aug 2026 16:55:07 +0200 Subject: [PATCH 02/12] reversible arms --- .../entity/entities/mek/mek-entity.spec.ts | 48 ++++++++++++++++++- .../models/entity/entities/mek/mek-entity.ts | 9 +++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/app/models/entity/entities/mek/mek-entity.spec.ts b/src/app/models/entity/entities/mek/mek-entity.spec.ts index 8b0e57ec7..0e44f53b2 100644 --- a/src/app/models/entity/entities/mek/mek-entity.spec.ts +++ b/src/app/models/entity/entities/mek/mek-entity.spec.ts @@ -65,13 +65,36 @@ describe('MekEntity optional systems', () => { describe('MekEntity features', () => { + function removeArmActuators(entity: BipedMekEntity): void { + entity.hasLowerArmActuator.set({ left: false, right: false }); + entity.hasHandActuator.set({ left: false, right: false }); + } + + function addSplitComponent( + entity: BipedMekEntity, + locations: readonly [string, string], + ): void { + const component = new WeaponEquipment({ + id: `split-${locations.join('-')}`, + name: 'Split Component', + type: 'weapon', + weapon: { damage: 5, ranges: [3, 6, 9, 12] }, + }); + addTestEquipment(entity, component, { + allocation: { + kind: 'location', + location: locations[1], + placements: locations.map((location, slotIndex) => ({ location, slotIndex })), + }, + }); + } + it('derives Reversible Arms when all lower-arm and hand actuators are absent', () => { const entity = new BipedMekEntity(); expect(entity.entityFeatures()).not.toContain('Reversible Arms'); - entity.hasLowerArmActuator.set({ left: false, right: false }); - entity.hasHandActuator.set({ left: false, right: false }); + removeArmActuators(entity); expect(entity.entityFeatures()).toEqual(['Reversible Arms']); entity.hasHandActuator.set({ left: true, right: false }); @@ -84,6 +107,27 @@ describe('MekEntity features', () => { expect(entity.entityFeatures()).not.toContain('Reversible Arms'); }); + for (const locations of [['LA', 'LT'], ['RA', 'RT']] as const) { + it(`does not derive Reversible Arms with a ${locations.join('/')} split component`, () => { + const entity = new BipedMekEntity(); + removeArmActuators(entity); + + expect(entity.entityFeatures()).toContain('Reversible Arms'); + + addSplitComponent(entity, locations); + + expect(entity.entityFeatures()).not.toContain('Reversible Arms'); + }); + } + + it('allows Reversible Arms when a split component does not occupy an arm', () => { + const entity = new BipedMekEntity(); + removeArmActuators(entity); + addSplitComponent(entity, ['LT', 'CT']); + + expect(entity.entityFeatures()).toContain('Reversible Arms'); + }); + it('does not add arm-specific features to a Quad Mek', () => { expect(new QuadMekEntity().entityFeatures()).not.toContain('Reversible Arms'); }); diff --git a/src/app/models/entity/entities/mek/mek-entity.ts b/src/app/models/entity/entities/mek/mek-entity.ts index 646856dd0..64df5969e 100644 --- a/src/app/models/entity/entities/mek/mek-entity.ts +++ b/src/app/models/entity/entities/mek/mek-entity.ts @@ -1011,7 +1011,14 @@ export abstract class MekWithArmsEntity extends MekEntity { const features = [...super.computeEntityFeatures()]; const lowerArms = this.hasLowerArmActuator(); const hands = this.hasHandActuator(); - if (!hands.left && !hands.right && !lowerArms.left && !lowerArms.right) { + const hasArmTorsoSplit = this.equipment().some(mount => { + const locations = new Set(mount.getOccupiedLocations()); + return (locations.has('LA') && locations.has('LT')) + || (locations.has('RA') && locations.has('RT')); + }); + if (!hasArmTorsoSplit + && !hands.left && !hands.right + && !lowerArms.left && !lowerArms.right) { features.push('Reversible Arms'); } return features; From f7629d7b8af7e73025e5f7778f60b949826a22f0 Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 8 Aug 2026 18:06:36 +0200 Subject: [PATCH 03/12] features --- src/app/models/entity/base-entity.ts | 26 ++++++++- .../entity/entities/aero/aero-entity.ts | 12 ++++ .../entities/aero/conv-fighter-entity.ts | 14 ++++- .../aero/fixed-wing-support-entity.ts | 24 +++++++- .../entities/largecraft/jumpship-entity.ts | 7 +++ .../entity/entities/mek/mek-entity.spec.ts | 20 ++++++- .../models/entity/entities/mek/mek-entity.ts | 41 ++++++++++++- .../entity/entities/vehicle/vehicle-entity.ts | 8 +++ src/app/models/entity/types/feature.ts | 23 +++++++- src/app/utils/unit-metadata-builder.spec.ts | 47 ++++++++++++++- src/app/utils/unit-metadata-builder.ts | 58 ------------------- 11 files changed, 214 insertions(+), 66 deletions(-) diff --git a/src/app/models/entity/base-entity.ts b/src/app/models/entity/base-entity.ts index 5332adc7d..9c689fee8 100644 --- a/src/app/models/entity/base-entity.ts +++ b/src/app/models/entity/base-entity.ts @@ -77,6 +77,7 @@ import { uuidv7 } from '../../utils/uuid.util'; import type { SupportVehicle } from './entities/support-vehicle'; import type { UnitSubtype, UnitType } from './types'; import { EquipmentRegistry } from '../equipment-lookup'; +import { getBayTransporterType, isQuartersBay } from './bays/bay-definitions'; import { CLAN_EXCEPTIONAL_BAY_IDS, weaponBayEquipmentId } from './utils/implicit-equipment'; import { calculateEntityCostDetails } from './utils/cost/entity-cost'; import { @@ -1069,7 +1070,30 @@ export abstract class BaseEntity implements EntityTechnology { /** Override in entity families that derive named features from construction state. */ protected computeEntityFeatures(): readonly EntityFeature[] { - return []; + return this.computeTransportFeatures(); + } + + protected computeTransportFeatures(): readonly EntityFeature[] { + const features = new Set(); + for (const transporter of this.transporters()) { + if (transporter.kind === 'troop-space') { + features.add('Infantry Compartment'); + } else if (transporter.kind === 'bay' && !isQuartersBay(transporter)) { + features.add(`Bay: ${getBayTransporterType(transporter.configuration)}` as EntityFeature); + } + } + return [...features]; + } + + protected computeChassisModificationFeatures(): readonly EntityFeature[] { + const features = new Set(); + for (const mount of this.equipment()) { + const equipment = mount.equipment; + if (equipment?.hasFlag('F_CHASSIS_MODIFICATION')) { + features.add(`Chassis Mod: ${equipment.shortName}` as EntityFeature); + } + } + return [...features]; } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/app/models/entity/entities/aero/aero-entity.ts b/src/app/models/entity/entities/aero/aero-entity.ts index a843c4f85..2c99de0df 100644 --- a/src/app/models/entity/entities/aero/aero-entity.ts +++ b/src/app/models/entity/entities/aero/aero-entity.ts @@ -10,6 +10,7 @@ import { type UnitSubtype, type MovementCalculationOptions, type TechRatingSource, + type EntityFeature, AeroCockpitType, ASF_WEIGHT_LIMITS, EntityType, @@ -47,6 +48,17 @@ export abstract class AeroEntity extends BaseEntity { abstract override unitSubtype(): UnitSubtype; + protected computeAeroFeatures(): readonly EntityFeature[] { + const features: EntityFeature[] = []; + if (this.cockpitType() === 'Small') features.push('Small Cockpit'); + else if (this.cockpitType() === 'Command Console') features.push('Command Console'); + return features; + } + + protected override computeEntityFeatures(): readonly EntityFeature[] { + return [...this.computeAeroFeatures(), ...this.computeTransportFeatures()]; + } + protected override omniTechAdvancement(): TechRatingSource | null { // MegaMek includes the Omni system advancement for Inner Sphere // OmniFighters, while Clan OmniFighter availability is equipment-derived. diff --git a/src/app/models/entity/entities/aero/conv-fighter-entity.ts b/src/app/models/entity/entities/aero/conv-fighter-entity.ts index 9860818bb..79fed3493 100644 --- a/src/app/models/entity/entities/aero/conv-fighter-entity.ts +++ b/src/app/models/entity/entities/aero/conv-fighter-entity.ts @@ -3,7 +3,13 @@ // Author: Drake import { signal } from '@angular/core'; -import { AERO_EQUIP_LOCATIONS, AERO_LOCATIONS, EntityType, type TechRatingSource } from '../../types'; +import { + AERO_EQUIP_LOCATIONS, + AERO_LOCATIONS, + EntityType, + type EntityFeature, + type TechRatingSource, +} from '../../types'; import { AeroEntity } from './aero-entity'; import { getConventionalFighterConstructionTech } from '../../components'; import type { UnitSubtype } from '../../types'; @@ -13,6 +19,12 @@ export class ConvFighterEntity extends AeroEntity { override readonly entityType: EntityType = 'ConvFighter'; vstol = signal(false); + protected override computeAeroFeatures(): readonly EntityFeature[] { + const features = [...super.computeAeroFeatures()]; + if (this.vstol()) features.push('VSTOL Equipment'); + return features; + } + override unitSubtype(): UnitSubtype { return this.withOmniSubtype('Conventional Fighter'); } diff --git a/src/app/models/entity/entities/aero/fixed-wing-support-entity.ts b/src/app/models/entity/entities/aero/fixed-wing-support-entity.ts index 67427d263..9841e3c04 100644 --- a/src/app/models/entity/entities/aero/fixed-wing-support-entity.ts +++ b/src/app/models/entity/entities/aero/fixed-wing-support-entity.ts @@ -4,7 +4,13 @@ import { computed } from '@angular/core'; import { SupportVehicleData, type SupportVehicle } from '../support-vehicle'; -import { AERO_LOCATIONS, EntityType, FIXED_WING_EQUIP_LOCATIONS, WeightClass } from '../../types'; +import { + AERO_LOCATIONS, + EntityType, + FIXED_WING_EQUIP_LOCATIONS, + type EntityFeature, + WeightClass, +} from '../../types'; import { AeroEntity } from './aero-entity'; import type { UnitSubtype } from '../../types'; import type { TechRatingSource } from '../../types'; @@ -30,6 +36,22 @@ export class FixedWingSupportEntity extends AeroEntity implements SupportVehicle readonly barRating = this.supportVehicle.barRating; readonly structuralTechRating = this.supportVehicle.structuralTechRating; readonly engineTechRating = this.supportVehicle.engineTechRating; + + protected override computeAeroFeatures(): readonly EntityFeature[] { + const features = [...super.computeAeroFeatures()]; + if (this.equipment().some(mount => mount.equipment?.hasFlag('F_VSTOL_CHASSIS'))) { + features.push('VSTOL Equipment'); + } + return features; + } + + protected override computeEntityFeatures(): readonly EntityFeature[] { + return [ + ...this.computeAeroFeatures(), + ...this.computeChassisModificationFeatures(), + ...this.computeTransportFeatures(), + ]; + } /** Maximum bomb payload, derived from external hardpoints and Internal Bomb Bay cargo space. */ readonly maxBombPoints = computed(() => { diff --git a/src/app/models/entity/entities/largecraft/jumpship-entity.ts b/src/app/models/entity/entities/largecraft/jumpship-entity.ts index 2912b859f..62135b68e 100644 --- a/src/app/models/entity/entities/largecraft/jumpship-entity.ts +++ b/src/app/models/entity/entities/largecraft/jumpship-entity.ts @@ -13,6 +13,7 @@ import { LARGE_CRAFT_LOCATIONS, resolveWeightClass, WeightClass, + type EntityFeature, } from '../../types'; import type { UnitSubtype } from '../../types'; import type { TechRatingSource } from '../../types'; @@ -45,6 +46,12 @@ export class JumpShipEntity extends LargeAeroEntity { return this.withOmniSubtype('JumpShip'); } + protected override computeAeroFeatures(): readonly EntityFeature[] { + const features = [...super.computeAeroFeatures()]; + if (this.lithiumFusion()) features.push('LF Battery'); + return features; + } + override entityTechAdvancements(): readonly TechRatingSource[] { return [getJumpshipConstructionTech(this.driveCoreType() === 'Primitive')]; } diff --git a/src/app/models/entity/entities/mek/mek-entity.spec.ts b/src/app/models/entity/entities/mek/mek-entity.spec.ts index 0e44f53b2..f3326a0ea 100644 --- a/src/app/models/entity/entities/mek/mek-entity.spec.ts +++ b/src/app/models/entity/entities/mek/mek-entity.spec.ts @@ -95,7 +95,7 @@ describe('MekEntity features', () => { expect(entity.entityFeatures()).not.toContain('Reversible Arms'); removeArmActuators(entity); - expect(entity.entityFeatures()).toEqual(['Reversible Arms']); + expect(entity.entityFeatures()).toEqual(jasmine.arrayWithExactContents(['Reversible Arms'])); entity.hasHandActuator.set({ left: true, right: false }); expect(entity.entityFeatures()).not.toContain('Reversible Arms'); @@ -107,6 +107,24 @@ describe('MekEntity features', () => { expect(entity.entityFeatures()).not.toContain('Reversible Arms'); }); + it('derives the Mek features exported by SVGMassPrinter', () => { + const entity = new BipedMekEntity(); + entity.cockpitType.set('Small'); + entity.gyroType.set('XL'); + entity.hasFullHeadEjectionSystem.set(true); + entity.hasRiscHeatSinkOverrideKit.set(true); + entity.setTonnage(50); + entity.setStructureAt('LA', standardStructure(70)); + + expect(entity.entityFeatures()).toEqual(jasmine.arrayWithExactContents([ + 'Small Cockpit', + 'XL Gyro', + 'Full Head Ejection System', + 'RISC Heat Sink Override Kit', + 'FrankenMek', + ])); + }); + for (const locations of [['LA', 'LT'], ['RA', 'RT']] as const) { it(`does not derive Reversible Arms with a ${locations.join('/')} split component`, () => { const entity = new BipedMekEntity(); diff --git a/src/app/models/entity/entities/mek/mek-entity.ts b/src/app/models/entity/entities/mek/mek-entity.ts index 64df5969e..d91ebc237 100644 --- a/src/app/models/entity/entities/mek/mek-entity.ts +++ b/src/app/models/entity/entities/mek/mek-entity.ts @@ -72,6 +72,24 @@ function jumpJetTonnage(unitTonnage: number): number { return 2; } +const MEK_COCKPIT_FEATURES: Readonly>> = { + Small: 'Small Cockpit', + 'Command Console': 'Command Console', + 'Torso-Mounted': 'Torso-Mounted Cockpit', + Dual: 'Dual Cockpit', + Interface: 'Interface Cockpit', + 'Virtual Reality Piloting Pod': 'Virtual Reality Piloting Pod', + 'Superheavy Command Console': 'Superheavy Command Console', + 'Small Command Console': 'Small Command Console', +}; + +const MEK_GYRO_FEATURES: Readonly>> = { + XL: 'XL Gyro', + Compact: 'Compact Gyro', + 'Heavy Duty': 'Heavy Duty Gyro', + Superheavy: 'Superheavy Gyro', +}; + export abstract class MekEntity extends BaseEntity { override componentLocationOrder(): readonly string[] { if (this.chassisConfig === 'Quad') return ['HD', 'CT', 'RT', 'LT', 'FRL', 'FLL', 'RRL', 'RLL']; @@ -436,6 +454,22 @@ export abstract class MekEntity extends BaseEntity { }); } + protected computeMekFeatures(): readonly EntityFeature[] { + const features: EntityFeature[] = []; + const cockpitFeature = MEK_COCKPIT_FEATURES[this.cockpitType()]; + if (cockpitFeature) features.push(cockpitFeature); + const gyroFeature = MEK_GYRO_FEATURES[this.gyroType()]; + if (gyroFeature) features.push(gyroFeature); + if (this.hasFullHeadEjectionSystem()) features.push('Full Head Ejection System'); + if (this.hasRiscHeatSinkOverrideKit()) features.push('RISC Heat Sink Override Kit'); + if (this.hasHybridStructure()) features.push('FrankenMek'); + return features; + } + + protected override computeEntityFeatures(): readonly EntityFeature[] { + return [...this.computeMekFeatures(), ...this.computeTransportFeatures()]; + } + protected override computeIntrinsicWeapons(): readonly IntrinsicWeapon[] { const attacks: IntrinsicWeapon[] = []; const tsm = this.equipment().some(mount => @@ -1007,11 +1041,14 @@ export abstract class MekWithArmsEntity extends MekEntity { hasLowerArmActuator = signal<{ left: boolean; right: boolean }>({ left: true, right: true }); hasHandActuator = signal<{ left: boolean; right: boolean }>({ left: true, right: true }); - protected override computeEntityFeatures(): readonly EntityFeature[] { - const features = [...super.computeEntityFeatures()]; + protected override computeMekFeatures(): readonly EntityFeature[] { + const features = [...super.computeMekFeatures()]; const lowerArms = this.hasLowerArmActuator(); const hands = this.hasHandActuator(); const hasArmTorsoSplit = this.equipment().some(mount => { + if (mount.equipment?.type !== 'weapon' || !mount.isSplitAcrossLocations) { + return false; + } const locations = new Set(mount.getOccupiedLocations()); return (locations.has('LA') && locations.has('LT')) || (locations.has('RA') && locations.has('RT')); diff --git a/src/app/models/entity/entities/vehicle/vehicle-entity.ts b/src/app/models/entity/entities/vehicle/vehicle-entity.ts index 4e120bbfd..75447deb3 100644 --- a/src/app/models/entity/entities/vehicle/vehicle-entity.ts +++ b/src/app/models/entity/entities/vehicle/vehicle-entity.ts @@ -27,6 +27,7 @@ import { WeightClass, TechRatingSource, resolveWeightClass, + type EntityFeature, } from '../../types'; import { WeaponEquipment, type Equipment } from '../../../equipment.model'; @@ -63,6 +64,13 @@ export abstract class VehicleEntity extends BaseEntity { protected abstract vehicleConstructionTechAdvancement(): TechRatingSource; + protected override computeEntityFeatures(): readonly EntityFeature[] { + return [ + ...this.computeChassisModificationFeatures(), + ...this.computeTransportFeatures(), + ]; + } + protected override omniTechAdvancement(): TechRatingSource { return OMNI_VEHICLE_TECH; } diff --git a/src/app/models/entity/types/feature.ts b/src/app/models/entity/types/feature.ts index 8cc9faf0b..854ced211 100644 --- a/src/app/models/entity/types/feature.ts +++ b/src/app/models/entity/types/feature.ts @@ -3,4 +3,25 @@ // Author: Drake /** Canonical, export-ready features derived from entity construction state. */ -export type EntityFeature = 'Reversible Arms'; +export type EntityFeature = + | 'Small Cockpit' + | 'Command Console' + | 'Torso-Mounted Cockpit' + | 'Dual Cockpit' + | 'Interface Cockpit' + | 'Virtual Reality Piloting Pod' + | 'Superheavy Command Console' + | 'Small Command Console' + | 'XL Gyro' + | 'Compact Gyro' + | 'Heavy Duty Gyro' + | 'Superheavy Gyro' + | 'Full Head Ejection System' + | 'RISC Heat Sink Override Kit' + | 'FrankenMek' + | 'VSTOL Equipment' + | 'LF Battery' + | 'Infantry Compartment' + | `Chassis Mod: ${string}` + | `Bay: ${string}` + | 'Reversible Arms'; diff --git a/src/app/utils/unit-metadata-builder.spec.ts b/src/app/utils/unit-metadata-builder.spec.ts index 7980fcf85..9a5243361 100644 --- a/src/app/utils/unit-metadata-builder.spec.ts +++ b/src/app/utils/unit-metadata-builder.spec.ts @@ -85,7 +85,7 @@ describe('UnitMetadataBuilder', () => { entity.hasLowerArmActuator.set({ left: false, right: false }); entity.hasHandActuator.set({ left: false, right: false }); - expect(entity.entityFeatures()).toEqual(['Reversible Arms']); + expect(entity.entityFeatures()).toEqual(jasmine.arrayWithExactContents(['Reversible Arms'])); expect(builder.build(entity).features).toContain('Reversible Arms'); }); @@ -95,9 +95,54 @@ describe('UnitMetadataBuilder', () => { const metadata = builder.build(entity); expect(metadata.features).toContain('Small Cockpit'); + expect(metadata.features?.filter(feature => feature === 'Small Cockpit')).toHaveSize(1); expect(metadata.comp?.find(component => component.id === 'cockpit')?.n).toBe('Small Cockpit'); }); + it('derives Aero cockpit features canonically', () => { + const entity = new ConvFighterEntity(); + entity.cockpitType.set('Small'); + expect(entity.entityFeatures()).toEqual(jasmine.arrayWithExactContents(['Small Cockpit'])); + + entity.cockpitType.set('Command Console'); + expect(entity.entityFeatures()).toEqual(jasmine.arrayWithExactContents(['Command Console'])); + }); + + it('derives the remaining SVGMassPrinter feature categories', () => { + const fighter = new ConvFighterEntity(); + fighter.vstol.set(true); + expect(fighter.entityFeatures()).toEqual(jasmine.arrayWithExactContents(['VSTOL Equipment'])); + + const fixedWing = new FixedWingSupportEntity(); + addTestEquipmentWithFlags(fixedWing, 'F_VSTOL_CHASSIS'); + expect(fixedWing.entityFeatures()).toContain('VSTOL Equipment'); + + const jumpShip = new JumpShipEntity(); + jumpShip.lithiumFusion.set(true); + expect(jumpShip.entityFeatures()).toEqual(jasmine.arrayWithExactContents(['LF Battery'])); + + const vehicle = new SupportTankEntity(); + addTestEquipmentWithFlags(vehicle, 'F_CHASSIS_MODIFICATION'); + expect(vehicle.entityFeatures().some(feature => feature.startsWith('Chassis Mod: '))).toBeTrue(); + + const transport = new BipedMekEntity(); + transport.transporters.set([ + { id: 'troop-space', kind: 'troop-space', totalSpace: 1, omni: false }, + { + id: 'quarters', kind: 'bay', configuration: { type: 'crew-quarters' }, + capacity: 1, doors: 1, bayNumber: 0, omni: false, + }, + { + id: 'fighter-bay', kind: 'bay', configuration: { type: 'fighter', arts: false }, + capacity: 1, doors: 1, bayNumber: 0, omni: false, + }, + ]); + expect(transport.entityFeatures()).toEqual(jasmine.arrayWithExactContents([ + 'Infantry Compartment', + 'Bay: Fighter', + ])); + }); + it('exports the Java offensive speed factor from BV movement', () => { const entity = new BipedMekEntity(); entity.originalWalkMP.set(4); diff --git a/src/app/utils/unit-metadata-builder.ts b/src/app/utils/unit-metadata-builder.ts index f3624a7eb..fac86a300 100644 --- a/src/app/utils/unit-metadata-builder.ts +++ b/src/app/utils/unit-metadata-builder.ts @@ -3,17 +3,11 @@ // Author: Drake import { BaseEntity } from '../models/entity/base-entity'; -import { AeroEntity } from '../models/entity/entities/aero/aero-entity'; -import { ConvFighterEntity } from '../models/entity/entities/aero/conv-fighter-entity'; -import { FixedWingSupportEntity } from '../models/entity/entities/aero/fixed-wing-support-entity'; import { InfantryBaseEntity } from '../models/entity/entities/infantry/infantry-base-entity'; import { InfantryEntity } from '../models/entity/entities/infantry/infantry-entity'; import { JumpShipEntity } from '../models/entity/entities/largecraft/jumpship-entity'; -import { MekEntity } from '../models/entity/entities/mek/mek-entity'; -import { VehicleEntity } from '../models/entity/entities/vehicle/vehicle-entity'; import { Unit } from '../models/units.model'; import { EntityType, MoveType } from '../models/entity/types'; -import { getBayTransporterType, isQuartersBay } from '../models/entity/bays/bay-definitions'; import { buildUnitCargoMetadata } from './unit-cargo-metadata-builder'; import { buildUnitComponentMetadata } from './unit-component-metadata-builder'; import { EquipmentFlag } from '../models/equipment-flags.type'; @@ -160,64 +154,12 @@ export class UnitMetadataBuilder { private buildFeatures(entity: BaseEntity): string[] { const features: string[] = [...entity.entityFeatures()]; - if (entity instanceof AeroEntity) { - if (entity.cockpitType() === 'Small') features.push('Small Cockpit'); - else if (entity.cockpitType() === 'Command Console') features.push('Command Console'); - if (entity instanceof ConvFighterEntity && entity.vstol()) features.push('VSTOL Equipment'); - if (entity instanceof FixedWingSupportEntity && entity.equipment().some(mount => - mount.equipment?.hasFlag('F_VSTOL_CHASSIS'))) { - features.push('VSTOL Equipment'); - } - if (entity instanceof JumpShipEntity && entity.lithiumFusion()) features.push('LF Battery'); - } - - if (entity instanceof MekEntity) { - const featuredCockpits = new Set([ - 'Small', - 'Command Console', - 'Torso-Mounted', - 'Dual', - 'Interface', - 'Virtual Reality Piloting Pod', - 'Superheavy Command Console', - 'Small Command Console', - ]); - if (featuredCockpits.has(entity.cockpitType())) { - features.push(entity.mountedCockpit().fullName); - } - if (entity.gyroType() !== 'Standard' && entity.gyroType() !== 'None') { - features.push(entity.mountedGyro().fullName); - } - if (entity.hasFullHeadEjectionSystem()) features.push('Full Head Ejection System'); - if (entity.hasRiscHeatSinkOverrideKit()) features.push('RISC Heat Sink Override Kit'); - if (entity.hasHybridStructure()) features.push('FrankenMek'); - } - - if (entity.isSupportVehicle() || entity instanceof VehicleEntity) { - const chassisMods = new Set(); - for (const mount of entity.equipment()) { - if (mount.equipment?.hasFlag('F_CHASSIS_MODIFICATION')) { - chassisMods.add(`Chassis Mod: ${mount.equipment.shortName}`); - } - } - features.push(...chassisMods); - } - const hasEquipmentFlag = (flag: EquipmentFlag): boolean => entity.equipment().some( mount => mount.equipment?.hasFlag(flag), ); if (hasEquipmentFlag('F_ADVANCED_FIRE_CONTROL')) features.push('Advanced Fire Control'); else if (hasEquipmentFlag('F_BASIC_FIRE_CONTROL')) features.push('Basic Fire Control'); - const transportTypes = new Set(); - for (const transporter of entity.transporters()) { - if (transporter.kind === 'troop-space') { - transportTypes.add('Infantry Compartment'); - } else if (transporter.kind === 'bay' && !isQuartersBay(transporter)) { - transportTypes.add(`Bay: ${getBayTransporterType(transporter.configuration)}`); - } - } - features.push(...transportTypes); return features; } From 46807d944845228c67be4083d8bfce023acfcc97 Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 8 Aug 2026 19:20:05 +0200 Subject: [PATCH 04/12] . --- .../weapons-equipment-panel.component.html | 2 +- .../weapons-equipment-panel.component.scss | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.html b/src/app/components/equipment-dialog/weapons-equipment-panel.component.html index 582b8ae51..e4623d2e4 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.html +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.html @@ -132,7 +132,7 @@

- {{ rowTarget.hitText }} + {{ rowTarget.hitText }} {{ row.display.location }} @if (tracksHeat()) { {{ row.display.heat }} diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss b/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss index 5cd678510..eed50f498 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss @@ -330,8 +330,11 @@ .hit-cell { grid-column: 5; - font-weight: 800; - font-size: 1.2em; + + .hit-value { + font-weight: 800; + font-size: 1.2em; + } } .hit-cell.weakened { From f25823ca3f45b8e220f2d4e1e48ef1098450a9ae Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 8 Aug 2026 20:58:39 +0200 Subject: [PATCH 05/12] TN --- .../tn-calculator-dialog.component.spec.ts | 51 ++++++++++++++ .../tn-calculator-dialog.component.ts | 24 +------ src/app/models/cbt-force-unit.model.spec.ts | 70 +++++++++++++++++-- .../target-number-calculator.model.spec.ts | 15 ++++ .../models/target-number-calculator.model.ts | 2 +- ...nventory-control-opfor-target.util.spec.ts | 8 +-- .../inventory-control-opfor-target.util.ts | 6 +- 7 files changed, 141 insertions(+), 35 deletions(-) diff --git a/src/app/components/equipment-dialog/tn-calculator-dialog.component.spec.ts b/src/app/components/equipment-dialog/tn-calculator-dialog.component.spec.ts index 7777b77df..24bf017db 100644 --- a/src/app/components/equipment-dialog/tn-calculator-dialog.component.spec.ts +++ b/src/app/components/equipment-dialog/tn-calculator-dialog.component.spec.ts @@ -160,3 +160,54 @@ describe('TnCalculatorDialogComponent read-only target identity', () => { expect(component.unitType()).toBe('battle-armor'); }); }); + +describe('TnCalculatorDialogComponent movement and stance', () => { + it('retains independent movement, jump, and prone state', async () => { + const close = jasmine.createSpy('close'); + const data: TnCalculatorDialogData = { + target: { + id: 'A', + letter: 'A', + name: 'Target A', + color: '#1565C0', + distance: 8, + tnModifier: 0, + tnCalculator: { + stance: 'prone', + targetMovementBracket: '7-9', + isAirborne: true, + skidding: true + } + }, + gameRules: TW_GAME_RULES + }; + await TestBed.configureTestingModule({ + imports: [TnCalculatorDialogComponent], + providers: [ + { provide: DIALOG_DATA, useValue: data }, + { provide: DialogRef, useValue: { close } } + ] + }).compileComponents(); + const fixture = TestBed.createComponent(TnCalculatorDialogComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.stance()).toBe('prone'); + expect(component.targetMovementBracket().id).toBe('7-9'); + expect(component.isAirborne()).toBeTrue(); + expect(component.skidding()).toBeTrue(); + expect(component.totalModifier()).toBe(7); + + component.apply(); + + expect(close).toHaveBeenCalledWith(jasmine.objectContaining({ + patch: jasmine.objectContaining({ + tnModifier: 7, + tnCalculator: jasmine.objectContaining({ + stance: 'prone', + targetMovementBracket: '7-9' + }) + }) + })); + }); +}); diff --git a/src/app/components/equipment-dialog/tn-calculator-dialog.component.ts b/src/app/components/equipment-dialog/tn-calculator-dialog.component.ts index 6947bafd2..ba4108cd7 100644 --- a/src/app/components/equipment-dialog/tn-calculator-dialog.component.ts +++ b/src/app/components/equipment-dialog/tn-calculator-dialog.component.ts @@ -118,7 +118,7 @@ export interface TnCalculatorDialogResult { [label]="targetMovementBracketLabel()" [modifierLabel]="targetMovementModifierLabel()" [ariaLabel]="'Target movement bracket'" - [valueAssigned]="stance() === 'normal'" + [valueAssigned]="!staticTarget()" [compactLabel]="true" (valueChange)="setTargetMovementBracketIndex($event)"> @@ -855,7 +855,7 @@ export class TnCalculatorDialogComponent { unitType: this.unitType(), range: this.range(), isAirborne: this.isAirborne(), - targetMovementBracket: this.stance() === 'normal' ? this.targetMovementBracket().id : null, + targetMovementBracket: !this.staticTarget() ? this.targetMovementBracket().id : null, skidding: this.skidding(), stance: this.stance(), interveningWoods: this.interveningWoods(), @@ -902,11 +902,6 @@ export class TnCalculatorDialogComponent { constructor() { afterNextRender(() => this.renderReady.set(true)); - - if (this.stance() !== 'normal') { - this.clearAirborne(); - this.skidding.set(false); - } this.clearStaticTargetModifiers(); } @@ -923,29 +918,22 @@ export class TnCalculatorDialogComponent { setTargetMovementBracketIndex(value: number): void { if (this.staticTarget() || this.targetStateReadOnly) return; this.targetMovementBracketIndex.set(this.alignToStep(value, this.MOVEMENT_MIN, this.MOVEMENT_MAX)); - this.clearStanceForMovement(); } toggleAirborne(): void { if (this.staticTarget() || this.targetStateReadOnly) return; this.isAirborne.set(!this.isAirborne()); - this.clearStanceForMovement(); } toggleSkidding(): void { if (this.staticTarget() || this.targetStateReadOnly) return; this.skidding.set(!this.skidding()); - this.clearStanceForMovement(); } selectStance(stance: TnTargetStance): void { if (this.staticTarget() || this.targetStateReadOnly) return; const next = this.stance() === stance ? 'normal' : stance; this.stance.set(next); - if (next !== 'normal') { - this.clearAirborne(); - this.skidding.set(false); - } if (next === 'prone') { this.partialCover.set(false); } @@ -1030,7 +1018,7 @@ export class TnCalculatorDialogComponent { apply(): void { const state: TnTargetNumberCalculatorState = { isAirborne: this.staticTarget() ? false : this.isAirborne(), - targetMovementBracket: !this.staticTarget() && this.stance() === 'normal' ? this.targetMovementBracket().id : null, + targetMovementBracket: !this.staticTarget() ? this.targetMovementBracket().id : null, skidding: this.staticTarget() ? false : this.skidding(), stance: this.staticTarget() ? 'immobile' : this.stance(), interveningWoods: this.interveningWoods(), @@ -1073,12 +1061,6 @@ export class TnCalculatorDialogComponent { this.c3Distance.set(this.alignToStep(value, this.RANGE_MIN, this.RANGE_MAX)); } - private clearStanceForMovement(): void { - if (this.stance() !== 'normal') { - this.stance.set('normal'); - } - } - private clearStaticTargetModifiers(): void { if (!this.staticTarget()) return; this.targetMovementBracketIndex.set(0); diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index 0874f4a62..f5dd27c78 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { computed, Injector, signal } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { computed, Injector, provideZonelessChangeDetection, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { AmmoEquipment, Equipment, MiscEquipment, resolveWeaponDamage, WeaponEquipment, type EquipmentMap } from './equipment.model'; import { CBTForce } from './cbt-force.model'; @@ -33,6 +34,8 @@ import { EquipmentFlag } from './equipment-flags.type'; import { EquipmentRegistry } from './equipment-lookup'; import { OptionsService } from '../services/options.service'; import { formatPilotingDisplay } from './rules/unit-type-rules'; +import { createTestEquipmentState } from '../testing/unit-test-helpers'; +import { registerAllHandlers } from '../equipment-handlers'; function createEquipment(): EquipmentMap { const ultraAc20 = new WeaponEquipment({ @@ -728,6 +731,61 @@ class RunMovementBonusTestHandler extends EquipmentInteractionHandler { } } +describe('CBTForceUnit live catalog integration', () => { + xit('loads every unit available in the live catalog', async () => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideHttpClient(), + ], + }); + + const liveDataService = TestBed.inject(DataService); + const liveUnitInitializer = TestBed.inject(UnitInitializerService); + const liveInjector = TestBed.inject(Injector); + registerAllHandlers(TestBed.inject(EquipmentInteractionRegistryService)); + + await liveDataService.initialize(); + + const catalogUnits = liveDataService.getUnits(); + expect(liveDataService.isDataReady()) + .withContext('The live catalogs did not initialize successfully') + .toBeTrue(); + expect(catalogUnits.length) + .withContext('The live unit catalog is empty') + .toBeGreaterThan(0); + + const force = new TestCBTForce( + 'Live Catalog Test Force', + liveDataService, + liveUnitInitializer, + liveInjector, + ); + const failures: string[] = []; + + for (const catalogUnit of catalogUnits) { + let forceUnit: CBTForceUnit | undefined; + try { + forceUnit = force.createCompatibleUnit(catalogUnit); + await forceUnit.load(); + + if (!forceUnit.initialized || !forceUnit.isLoaded() || !forceUnit.svg() || !forceUnit.svgService) { + throw new Error('load completed without a fully initialized SVG unit'); + } + } catch (error) { + const message = error instanceof Error ? error.stack ?? error.message : String(error); + failures.push(`${catalogUnit.name} [${catalogUnit.sheets[0] ?? 'no sheet'}]: ${message}`); + } finally { + forceUnit?.destroy(); + } + } + + expect(failures) + .withContext(`Failed to load ${failures.length} of ${catalogUnits.length} live catalog units:\n${failures.join('\n')}`) + .toEqual([]); + }, 30 * 60 * 1000); +}); + describe('CBTForceUnit direct inventory ammo bins', () => { let equipment: EquipmentMap; let dataService: jasmine.SpyObj; @@ -855,7 +913,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { forceUnit.setInventory([weaponEntry, ammoEntry], true); expect(forceUnit.getInventoryControlSelectedAmmo(weaponEntry)).toBe(intrinsicAmmo); - expect(() => weaponEntry.ruleState()).not.toThrow(); + expect(() => weaponEntry.owner.rules.getEquipmentToHit(weaponEntry)).not.toThrow(); }); it('clones virtual inventory rows from a computed without writing signals', () => { @@ -3172,9 +3230,9 @@ describe('CBTForceUnit direct inventory ammo bins', () => { const module = laser.linkedWith![0]; const laserHitText = laser.el!.querySelector(':scope > .hitMod-text') as SVGTextElement; const moduleHitText = module.el!.querySelector(':scope > .hitMod-text') as SVGTextElement; - spyOn(forceUnit.rules, 'computeAllEntryStates').and.returnValue(new Map([ - [laser, { isDamaged: false, isDisabled: false, hitMod: 0 }], - [module, { isDamaged: false, isDisabled: false, hitMod: 1 }], + spyOn(forceUnit.rules, 'getEquipmentToHits').and.returnValue(new Map([ + [laser, createTestEquipmentState('available', []).toHit], + [module, createTestEquipmentState('available', [{ label: 'RISC Laser Pulse Module', modifier: 1 }]).toHit], ])); const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgVehicleService(forceUnit, unitInitializer)); @@ -3423,7 +3481,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { svgService.refreshCrew(); expect(forceUnit.turnState().getAttackModifierBreakdown()).not.toContain(jasmine.objectContaining({ label: 'Prone' })); - expect(forceUnit.rules.computeEntryState(ranged).hitModifierBreakdown) + expect(forceUnit.rules.getEquipmentToHit(ranged).modifiers) .toContain(jasmine.objectContaining({ label: 'Prone', modifier: 2 })); expect(svg.getElementById('gunnerySkill0')?.textContent).toBe('4'); }); diff --git a/src/app/models/target-number-calculator.model.spec.ts b/src/app/models/target-number-calculator.model.spec.ts index 63ca989d1..0d0ea1ed3 100644 --- a/src/app/models/target-number-calculator.model.spec.ts +++ b/src/app/models/target-number-calculator.model.spec.ts @@ -53,4 +53,19 @@ describe('target number calculator rules profiles', () => { expect(calculateTargetTnModifier({ unitType: 'terrain', range: 5 })).toBe(-4); expect(calculateTargetTnModifier({ unitType: 'building', range: 5 })).toBe(-4); }); + + it('keeps movement modifiers for prone and immobile non-static targets', () => { + expect(calculateTargetTnModifier({ + unitType: 'mek-biped', + range: 5, + targetMovementBracket: '7-9', + stance: 'prone' + })).toBe(4); + expect(calculateTargetTnModifier({ + unitType: 'mek-biped', + range: 5, + targetMovementBracket: '7-9', + stance: 'immobile' + })).toBe(-1); + }); }); diff --git a/src/app/models/target-number-calculator.model.ts b/src/app/models/target-number-calculator.model.ts index 0443370ca..acd7d3eaf 100644 --- a/src/app/models/target-number-calculator.model.ts +++ b/src/app/models/target-number-calculator.model.ts @@ -171,7 +171,7 @@ export function calculateTargetTnModifier( let total = 0; total += getTargetUnitTypeModifier(input.unitType); - if (!staticTarget && stance === 'normal') { + if (!staticTarget) { total += getTargetAirborneModifier(input.isAirborne); total += getTargetMovementBracketModifier(input.targetMovementBracket); total += gameRules.supportsSkidding && input.skidding ? TN_SKIDDING_MODIFIER : 0; diff --git a/src/app/utils/inventory-control-opfor-target.util.spec.ts b/src/app/utils/inventory-control-opfor-target.util.spec.ts index c395dc0b9..f7bb2eeaa 100644 --- a/src/app/utils/inventory-control-opfor-target.util.spec.ts +++ b/src/app/utils/inventory-control-opfor-target.util.spec.ts @@ -86,7 +86,7 @@ describe('inventory control OPFOR targets', () => { })); }); - it('gives immobile precedence and clears incompatible movement state', () => { + it('derives immobile stance without discarding movement and jump state', () => { const state = deriveOpforTargetCalculatorState(forceUnit({ conditions: ['immobile', 'prone', 'skidding'], distance: 12, @@ -95,9 +95,9 @@ describe('inventory control OPFOR targets', () => { expect(state).toEqual(jasmine.objectContaining({ stance: 'immobile', - targetMovementBracket: null, - isAirborne: false, - skidding: false, + targetMovementBracket: '10-17', + isAirborne: true, + skidding: true, interveningWoods: 'light1' })); }); diff --git a/src/app/utils/inventory-control-opfor-target.util.ts b/src/app/utils/inventory-control-opfor-target.util.ts index 424ef21e6..a7573fa96 100644 --- a/src/app/utils/inventory-control-opfor-target.util.ts +++ b/src/app/utils/inventory-control-opfor-target.util.ts @@ -45,15 +45,15 @@ export function deriveOpforTargetCalculatorState( const stance = immobile ? 'immobile' : prone ? 'prone' : 'normal'; const moveDistance = unit.turnState().moveDistance(); const isAirborne = unit.turnState().moveMode() === 'jump' || unit.turnState().airborne() === true; - const targetMovementBracket = stance === 'normal' && moveDistance !== null + const targetMovementBracket = moveDistance !== null ? getTargetMovementBracketForDistance(moveDistance)?.id ?? null : null; return { ...current, - isAirborne: stance === 'normal' && isAirborne, + isAirborne, targetMovementBracket, - skidding: stance === 'normal' && unit.getCondition('skidding'), + skidding: unit.getCondition('skidding'), stance, largeTarget: isLargeInventoryTarget(unit.getUnit()) }; From b3f2b5083dc94c58b7007ace6b36098bd427e5f4 Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 8 Aug 2026 21:06:17 +0200 Subject: [PATCH 06/12] deserialize failure test --- src/app/models/cbt-force.model.spec.ts | 113 +++++++++++++++++++++++++ src/app/models/force.model.ts | 9 ++ 2 files changed, 122 insertions(+) diff --git a/src/app/models/cbt-force.model.spec.ts b/src/app/models/cbt-force.model.spec.ts index 71b780723..91386f5b2 100644 --- a/src/app/models/cbt-force.model.spec.ts +++ b/src/app/models/cbt-force.model.spec.ts @@ -2,7 +2,15 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake +import type { Injector } from '@angular/core'; +import type { DataService } from '../services/data.service'; +import { DialogsService } from '../services/dialogs.service'; +import { LoggerService } from '../services/logger.service'; +import type { UnitInitializerService } from '../services/unit-initializer.service'; +import { GameSystem } from './common.model'; +import { CBTForceUnit } from './cbt-force-unit.model'; import { CBTForce } from './cbt-force.model'; +import type { CBTSerializedForce, CBTSerializedUnit } from './force-serialization'; describe('CBTForce pilot transfer', () => { function createCrew( @@ -66,4 +74,109 @@ describe('CBTForce pilot transfer', () => { expect(targetCrew.setSkill).toHaveBeenCalledWith('piloting', 5); expect(targetCrew.setSkill).not.toHaveBeenCalledWith('piloting', 0); }); +}); + +describe('CBTForce deserialization failures', () => { + let dialogs: jasmine.SpyObj; + let logger: jasmine.SpyObj; + let dataService: DataService; + let injector: Injector; + + function createSerializedUnit(id: string, unit: string): CBTSerializedUnit { + return { + id, + unit, + state: { + modified: false, + destroyed: false, + crew: [], + crits: [], + locations: {}, + heat: { current: 0, previous: 0 }, + }, + }; + } + + function createSerializedForce(unitNames: string[]): CBTSerializedForce { + return { + version: 1, + timestamp: new Date().toISOString(), + instanceId: 'force-id', + type: GameSystem.CLASSIC, + name: 'Partially valid force', + groups: [{ + id: 'group-id', + units: unitNames.map((name, index) => createSerializedUnit(`unit-${index}`, name)), + }], + }; + } + + function createLoadedUnit(data: CBTSerializedUnit): CBTForceUnit { + return { + id: data.id, + getUnit: () => ({ name: data.unit }), + } as CBTForceUnit; + } + + beforeEach(() => { + dialogs = jasmine.createSpyObj('DialogsService', ['showError']); + dialogs.showError.and.resolveTo(); + logger = jasmine.createSpyObj('LoggerService', ['error']); + dataService = { + getUnitByName: () => undefined, + getFactionById: () => null, + getEraById: () => null, + } as unknown as DataService; + injector = { + get: (token: unknown) => { + if (token === DialogsService) return dialogs; + if (token === LoggerService) return logger; + throw new Error(`Unexpected injector token: ${String(token)}`); + }, + } as Injector; + }); + + it('reports and skips a unit that is not found while loading the rest of the force', () => { + const deserialize = CBTForceUnit.deserialize; + spyOn(CBTForceUnit, 'deserialize').and.callFake((data, force, service, initializer, unitInjector) => { + if (data.unit === 'Missing Unit') { + return deserialize(data, force, service, initializer, unitInjector); + } + return createLoadedUnit(data); + }); + + const force = CBTForce.deserialize( + createSerializedForce(['Valid Unit A', 'Missing Unit', 'Valid Unit B']), + dataService, + {} as UnitInitializerService, + injector, + ); + + expect(force.units().map(unit => unit.getUnit().name)).toEqual(['Valid Unit A', 'Valid Unit B']); + expect(dialogs.showError).toHaveBeenCalledOnceWith( + 'Unable to load unit "Missing Unit". The unit was skipped.\n\n' + + 'Unit with name "Missing Unit" not found in dataService', + 'Unit Load Error', + ); + }); + + it('reports and skips a unit that throws an arbitrary error while loading the rest of the force', () => { + spyOn(CBTForceUnit, 'deserialize').and.callFake((data) => { + if (data.unit === 'Broken Unit') throw new TypeError('Invalid serialized state'); + return createLoadedUnit(data); + }); + + const force = CBTForce.deserialize( + createSerializedForce(['Valid Unit', 'Broken Unit']), + dataService, + {} as UnitInitializerService, + injector, + ); + + expect(force.units().map(unit => unit.getUnit().name)).toEqual(['Valid Unit']); + expect(dialogs.showError).toHaveBeenCalledOnceWith( + 'Unable to load unit "Broken Unit". The unit was skipped.\n\nInvalid serialized state', + 'Unit Load Error', + ); + }); }); \ No newline at end of file diff --git a/src/app/models/force.model.ts b/src/app/models/force.model.ts index 353720afa..7a7b7059f 100644 --- a/src/app/models/force.model.ts +++ b/src/app/models/force.model.ts @@ -25,6 +25,7 @@ import { MULFACTION_EXTINCT } from './mulfactions.model'; import { createMulForceAvailabilityContext, type ForceAvailabilityContext } from '../utils/force-availability.util'; import { uuidv7 } from '../utils/uuid.util'; import { C3Network, C3TaxCalculator, type C3TaxUnit } from './c3-network.model'; +import { DialogsService } from '../services/dialogs.service'; export const MAX_GROUPS = 50; @@ -885,6 +886,7 @@ export abstract class Force { } const logger = this.injector.get(LoggerService); + const dialogs = this.injector.get(DialogsService); const parsedGroups: UnitGroup[] = []; for (const g of sanitizedData.groups) { const groupUnits: TUnit[] = []; @@ -893,6 +895,13 @@ export abstract class Force { groupUnits.push(this.deserializeForceUnit(unitData)); } catch (err) { logger.error(`Force.deserialize error on unit "${unitData.unit}": ${err}`); + const errorDetail = err instanceof Error ? err.message : String(err); + void dialogs.showError( + `Unable to load unit "${unitData.unit}". The unit was skipped.\n\n${errorDetail}`, + 'Unit Load Error', + ).catch(dialogError => { + logger.error(`Unable to show unit load error dialog: ${dialogError}`); + }); continue; } } From 1deca10af9b081935eb13963ce712a6a374afa61 Mon Sep 17 00:00:00 2001 From: exeea Date: Sat, 8 Aug 2026 21:16:53 +0200 Subject: [PATCH 07/12] initial fix for the cycling issue --- .../c3-network-dialog.component.spec.ts | 9 +- .../weapons-equipment-panel.component.spec.ts | 90 +++--- .../svg-interaction.service.spec.ts | 4 +- .../page-viewer/svg-interaction.service.ts | 6 +- .../equipment-handlers/apollo.handler.spec.ts | 7 +- .../artemis-v.handler.spec.ts | 7 +- .../equipment-handlers/atm.handler.spec.ts | 6 +- .../bombast-laser.handler.spec.ts | 13 +- .../c3-emergency-master.handler.spec.ts | 7 +- .../disabled-equipment.handler.spec.ts | 15 +- .../equipment-handlers/hag.handler.spec.ts | 5 +- .../laser-insulator.handler.spec.ts | 7 +- .../equipment-handlers/masc.handler.spec.ts | 9 +- .../equipment-handlers/mml.handler.spec.ts | 6 +- .../ppc-capacitor.handler.spec.ts | 13 +- .../risc-laser-pulse-module.handler.spec.ts | 10 +- .../stealth.handler.spec.ts | 3 +- .../uacjamming.handler.spec.ts | 13 +- .../vibroblade.handler.spec.ts | 13 +- src/app/models/cbt-force-unit-c3.spec.ts | 3 +- .../models/mounted-equipment.model.spec.ts | 9 +- src/app/models/mounted-equipment.model.ts | 12 +- src/app/models/rules/aero-rules.spec.ts | 36 +-- src/app/models/rules/game-rules.spec.ts | 8 +- src/app/models/rules/infantry-rules.spec.ts | 2 +- src/app/models/rules/infantry-rules.ts | 12 +- src/app/models/rules/mek-rules.spec.ts | 300 ++++++++++-------- src/app/models/rules/mek-rules.ts | 82 +++-- src/app/models/rules/unit-type-rules.ts | 65 ++-- src/app/models/rules/vehicle-rules.spec.ts | 62 ++-- src/app/models/rules/vehicle-rules.ts | 37 +-- ...pment-interaction-registry.service.spec.ts | 8 +- src/app/services/unit-svg-mek.service.ts | 25 +- src/app/services/unit-svg-vehicle.service.ts | 25 +- src/app/services/unit-svg.service.ts | 15 +- src/app/testing/unit-test-helpers.spec.ts | 2 +- src/app/testing/unit-test-helpers.ts | 64 +++- src/app/utils/inventory-control.util.ts | 22 +- .../inventory-target-number.util.spec.ts | 7 +- 39 files changed, 584 insertions(+), 455 deletions(-) diff --git a/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts b/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts index b637437a1..fcf8339b9 100644 --- a/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts +++ b/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts @@ -10,6 +10,7 @@ import type { Force } from '../../models/force.model'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; import type { SerializedC3NetworkGroup } from '../../models/force-serialization'; import { MountedEquipment } from '../../models/mounted-equipment.model'; +import { createTestEquipmentRules } from '../../testing/unit-test-helpers'; import { C3Capabilities, C3_FLAGS, @@ -90,10 +91,10 @@ function c3UnitWithComponents(id: string, componentFlags: readonly (readonly str }, rules: { calculateC3Tax: () => 0, - computeEntryState: (entry: MountedEquipment) => ({ - isDamaged: destroyedComponents().has(inventory.indexOf(entry)), - isDisabled: false, - hitMod: 0, + ...createTestEquipmentRules({ + getEquipmentStatus: (entry: MountedEquipment) => ( + destroyedComponents().has(inventory.indexOf(entry)) ? 'destroyed' : 'available' + ), }), }, } as unknown as CBTForceUnit; diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts index 9a0c8a53f..833070560 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts @@ -26,7 +26,7 @@ import type { EquipmentDialogContext } from './equipment-dialog.model'; import type { MotiveModes } from '../../models/motiveModes.model'; import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../../models/rules/unit-type-rules'; import { ATTACK_MOVEMENT_MODIFIER_BREAKDOWN_PRIORITY, CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules, type C3DegradationSource, SKILL_BREAKDOWN_PRIORITY } from '../../models/rules/game-rules'; -import { createCBTForceUnitTestHarness, type CBTForceUnitTestEntryState, type TestUnitOverrides } from '../../testing/unit-test-helpers'; +import { createCBTForceUnitTestHarness, createTestEquipmentState, type CBTForceUnitTestEntryState, type TestUnitOverrides } from '../../testing/unit-test-helpers'; import { getVibrobladeMode, VIBROBLADE_MODE_STATE, VIBROBLADE_ON_MODE, VibrobladeHandler } from '../../equipment-handlers/vibroblade.handler'; import { EquipmentFlag } from '../../models/equipment-flags.type'; import { EquipmentRegistry } from '../../models/equipment-lookup'; @@ -276,21 +276,13 @@ describe('WeaponsEquipmentPanelComponent', () => { const charge = entry({ id: 'Charge', intrinsicPhysicalAttack: true }); const deathFromAbove = entry({ id: 'Death From Above', intrinsicPhysicalAttack: true }); const entryStates = new Map([ - [charge, { - isDamaged: false, - isDisabled: false, - hitMod: 3, - hitModifierBreakdown: [ + [charge, createTestEquipmentState('available', [ { label: 'Damaged actuator', modifier: 1, weakened: true }, { label: 'Prone', modifier: 2 } - ] - }], - [deathFromAbove, { - isDamaged: false, - isDisabled: false, - hitMod: -1, - hitModifierBreakdown: [{ label: 'Dedicated Pilot', modifier: -1 }] - }] + ])], + [deathFromAbove, createTestEquipmentState('available', [ + { label: 'Dedicated Pilot', modifier: -1 } + ])] ]); const { component, fixture, unit } = createComponent([charge, deathFromAbove], {}, [], entryStates); const rows = component.groups().find(group => group.id === 'physical')!.rows; @@ -481,7 +473,7 @@ describe('WeaponsEquipmentPanelComponent', () => { it('shows rule-damaged inventory rows as destroyed', () => { const laser = entry({ id: 'laser', equipment: weapon('laser'), destroyed: false, el: svgEntry('Laser') }); const entryStates = new Map([ - [laser, { isDamaged: true, isDisabled: false, hitMod: 0 }] + [laser, createTestEquipmentState('destroyed', [])] ]); const { component } = createComponent([laser], {}, [], entryStates); @@ -570,7 +562,7 @@ describe('WeaponsEquipmentPanelComponent', () => { it('marks rows disabled from entry state rules', () => { const laser = entry({ id: 'laser', equipment: weapon('laser'), el: svgEntry('Laser') }); const entryStates = new Map([ - [laser, { isDamaged: false, isDisabled: true, hitMod: 0 }] + [laser, createTestEquipmentState('disabled', [])] ]); const { component } = createComponent([laser], {}, [], entryStates); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; @@ -839,7 +831,7 @@ describe('WeaponsEquipmentPanelComponent', () => { }); apollo.parent = mrm; const entryStates = new Map([ - [apollo, { isDamaged: true, isDisabled: false, hitMod: 0 }] + [apollo, createTestEquipmentState('destroyed', [])] ]); const { component, fixture } = createComponent([mrm, apollo], {}, [], entryStates, { gameRules: TW_GAME_RULES }); @@ -865,15 +857,13 @@ describe('WeaponsEquipmentPanelComponent', () => { equipment: weapon('ER Medium Laser'), el: svgEntry('ER Medium Laser51015') }); - const entryStates = new Map([[laser, { - isDamaged: false, - isDisabled: false, - hitMod: 0, - hitModifierBreakdown: [ + const entryStates = new Map([[laser, createTestEquipmentState( + 'available', + [ { label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' }, { label: 'Targeting Computer', modifier: -1 } ] - }]]); + )]]); const { component, fixture } = createComponent([laser], {}, [], entryStates); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; const targetState = component.targetState(row); @@ -898,12 +888,10 @@ describe('WeaponsEquipmentPanelComponent', () => { equipment: weapon('ER Medium Laser'), el: svgEntry('ER Medium Laser51015') }); - const entryStates = new Map([[laser, { - isDamaged: false, - isDisabled: false, - hitMod: 0, - hitModifierBreakdown: [{ label: 'Targeting Computer Destroyed', modifier: 0, weakened: true }] - }]]); + const entryStates = new Map([[laser, createTestEquipmentState( + 'available', + [{ label: 'Targeting Computer Destroyed', modifier: 0, weakened: true }] + )]]); const { component, fixture } = createComponent([laser], {}, [], entryStates); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; const hitCell = fixture.nativeElement.querySelector('.hit-cell') as HTMLElement; @@ -920,17 +908,15 @@ describe('WeaponsEquipmentPanelComponent', () => { equipment: weapon('Pulse Laser', 'NA', 0, [3, 6, 9, 12], -1), el: svgEntry('Pulse Laser369') }); - const entryStates = new Map([[laser, { - isDamaged: false, - isDisabled: false, - hitMod: -1, - hitModifierBreakdown: [ + const entryStates = new Map([[laser, createTestEquipmentState( + 'available', + [ { label: 'Damaged Fire Control', modifier: 1, weakened: true }, { label: 'Targeting Computer', modifier: -1 }, { label: 'Heat - Fire Modifier', modifier: 0, weakened: true, kind: 'heat' }, { label: 'Pulse Module', modifier: -1 } ] - }]]); + )]]); const { component, fixture } = createComponent([laser], {}, [], entryStates); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; const hitCell = fixture.nativeElement.querySelector('.hit-cell') as HTMLElement; @@ -1669,8 +1655,8 @@ describe('WeaponsEquipmentPanelComponent', () => { const broken = entry({ id: 'broken', equipment: weapon('broken'), destroyed: true, el: svgEntry('Broken') }); const disabled = entry({ id: 'disabled', equipment: weapon('disabled'), el: svgEntry('Disabled') }); const punch = entry({ id: 'punch', intrinsicPhysicalAttack: true, el: svgEntry('Punch') }); - const entryStates = new Map([ - [disabled, { isDamaged: false, isDisabled: true, hitMod: 0 }] + const entryStates = new Map([ + [disabled, createTestEquipmentState('disabled', [])] ]); const { component, fixture, unit } = createComponent([first, second, broken, disabled, punch], {}, [], entryStates); unit.createInventoryControlTarget(); @@ -1711,7 +1697,13 @@ describe('WeaponsEquipmentPanelComponent', () => { it('uses assigned target distance for range selection and target number math', () => { const laser = entry({ id: 'laser', equipment: weapon('laser', 'NA', 0, [3, 6, 9, 12]), el: svgEntry('Wrong SVG Name99999999') }); - const { component, fixture, unit } = createComponent([laser], {}, [], new Map([[laser, { isDamaged: false, isDisabled: false, hitMod: 1 }]]), { gunnerySkill: 4, moveMode: 'run' }); + const { component, fixture, unit } = createComponent( + [laser], + {}, + [], + new Map([[laser, createTestEquipmentState('available', [{ label: 'Hit Modifier', modifier: 1 }])]]), + { gunnerySkill: 4, moveMode: 'run' } + ); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; unit.createInventoryControlTarget(); unit.updateInventoryControlTarget('A', { distance: 8, tnModifier: 1 }); @@ -1955,15 +1947,10 @@ describe('WeaponsEquipmentPanelComponent', () => { it('shows heat fire modifiers as a separate target number term', () => { const laser = entry({ id: 'laser', equipment: weapon('laser', 'NA', 0, [3, 6, 9, 12]), el: svgEntry('Wrong SVG Name999999') }); - const { component, fixture, unit } = createComponent([laser], {}, [], new Map([[laser, { - isDamaged: false, - isDisabled: false, - hitMod: 3, - hitModifierBreakdown: [ + const { component, fixture, unit } = createComponent([laser], {}, [], new Map([[laser, createTestEquipmentState('available', [ { label: 'Hit Modifier', modifier: 1 }, { label: 'Heat - Fire Modifier', modifier: 2, weakened: true, kind: 'heat' } - ] - }]]), { gunnerySkill: 4, moveMode: 'stationary' }); + ])]]), { gunnerySkill: 4, moveMode: 'stationary' }); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; unit.createInventoryControlTarget(); unit.updateInventoryControlTarget('A', { distance: 4, tnModifier: 1 }); @@ -1986,14 +1973,9 @@ describe('WeaponsEquipmentPanelComponent', () => { it('extracts Aero heat from its entry-state hit modifier', () => { const laser = entry({ id: 'laser', equipment: weapon('laser', 'NA', 0, [3, 6, 9, 12]), el: svgEntry('Laser') }); - const { component, unit } = createComponent([laser], {}, [], new Map([[laser, { - isDamaged: false, - isDisabled: false, - hitMod: 1, - hitModifierBreakdown: [ + const { component, unit } = createComponent([laser], {}, [], new Map([[laser, createTestEquipmentState('available', [ { label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' } - ] - }]]), { gunnerySkill: 4, moveMode: 'stationary' }); + ])]]), { gunnerySkill: 4, moveMode: 'stationary' }); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; unit.createInventoryControlTarget(); unit.updateInventoryControlTarget('A', { distance: 1 }); @@ -2097,8 +2079,8 @@ describe('WeaponsEquipmentPanelComponent', () => { const broken = entry({ id: 'broken', equipment: weapon('broken'), destroyed: true, el: svgEntry('Broken') }); const disabled = entry({ id: 'disabled', equipment: weapon('disabled'), el: svgEntry('Disabled') }); const punch = entry({ id: 'punch', intrinsicPhysicalAttack: true, el: svgEntry('Punch') }); - const entryStates = new Map([ - [disabled, { isDamaged: false, isDisabled: true, hitMod: 0 }] + const entryStates = new Map([ + [disabled, createTestEquipmentState('disabled', [])] ]); const { component, fixture, unit } = createComponent([first, second, broken, disabled, punch], {}, [], entryStates); fixture.detectChanges(); diff --git a/src/app/components/page-viewer/svg-interaction.service.spec.ts b/src/app/components/page-viewer/svg-interaction.service.spec.ts index 8d3152b20..692cde86b 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -24,6 +24,7 @@ import { SvgInteractionService } from './svg-interaction.service'; import type { ZoomPanServiceInterface } from './zoom-pan.interface'; import { PageViewerStateService } from './internal/page-viewer-state.service'; import { CORE_2026_GAME_RULES } from '../../models/rules/game-rules'; +import { createTestEquipmentRules } from '../../testing/unit-test-helpers'; type SvgInteractionServicePrivate = { addSvgTapHandler( @@ -46,8 +47,7 @@ const NO_CONDITION_RULES = { conditionControls: [], crewStateControls: [], locationConditionControls: [], - computeAllEntryStates: () => new Map(), - computeEntryState: (entry: MountedEquipment) => ({ isDamaged: entry.committedDestroyed(), isDisabled: false, hitMod: 0 }), + ...createTestEquipmentRules(), heatDissipation: () => null, getBaseGunnerySkill: () => 4, getBasePilotingSkill: () => 5, diff --git a/src/app/components/page-viewer/svg-interaction.service.ts b/src/app/components/page-viewer/svg-interaction.service.ts index a3474448a..4074acfa4 100644 --- a/src/app/components/page-viewer/svg-interaction.service.ts +++ b/src/app/components/page-viewer/svg-interaction.service.ts @@ -1354,11 +1354,11 @@ export class SvgInteractionService { selectedAmmo, target: target.c3Distance === undefined ? target : { ...target, c3Distance: undefined } }); - const state = unit.rules.computeEntryState(entry); + const toHit = unit.rules.getEquipmentToHit(entry); const hitResolution = gameRules.resolveToHit({ subject: entry, - stateModifier: state.hitMod, - stateModifierBreakdown: state.hitModifierBreakdown, + stateModifier: toHit.modifier, + stateModifierBreakdown: toHit.modifiers, range: weaponRangeSelection?.range ?? null, adjustments: rules.resolveToHitAdjustments?.(entry, selectedAmmo) }); diff --git a/src/app/equipment-handlers/apollo.handler.spec.ts b/src/app/equipment-handlers/apollo.handler.spec.ts index 9d89565ed..e560b616e 100644 --- a/src/app/equipment-handlers/apollo.handler.spec.ts +++ b/src/app/equipment-handlers/apollo.handler.spec.ts @@ -5,6 +5,7 @@ import { MountedEquipment } from '../models/mounted-equipment.model'; import { WeaponEquipment, type AmmoType, type Equipment } from '../models/equipment.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import { APOLLO_MODE_STATE, APOLLO_SATURATION_MODE, APOLLO_STANDARD_MODE, ApolloHandler } from './apollo.handler'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { EquipmentFlag } from '../models/equipment-flags.type'; @@ -12,7 +13,11 @@ import { EquipmentFlag } from '../models/equipment-flags.type'; function owner(unavailableEntry?: MountedEquipment, gameRules: CBTGameRules = CORE_2026_GAME_RULES) { return { gameRules, - rules: { computeEntryState: (candidate: MountedEquipment) => ({ isDamaged: candidate === unavailableEntry || candidate.committedDestroyed(), isDisabled: false, hitMod: 0 }) }, + rules: createTestEquipmentRules({ + getEquipmentStatus: (candidate: MountedEquipment) => ( + candidate === unavailableEntry || candidate.committedDestroyed() ? 'destroyed' : 'available' + ), + }), setInventoryEntry: jasmine.createSpy('setInventoryEntry') } as never; } diff --git a/src/app/equipment-handlers/artemis-v.handler.spec.ts b/src/app/equipment-handlers/artemis-v.handler.spec.ts index 82a3da989..35b8583b6 100644 --- a/src/app/equipment-handlers/artemis-v.handler.spec.ts +++ b/src/app/equipment-handlers/artemis-v.handler.spec.ts @@ -4,13 +4,18 @@ import { MountedEquipment } from '../models/mounted-equipment.model'; import { Equipment, type AmmoEquipment } from '../models/equipment.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import { ArtemisVHandler } from './artemis-v.handler'; import { EquipmentFlag } from '../models/equipment-flags.type'; import { AmmoMunitionFlag } from '../models/ammo-munition-flags.type'; function owner(unavailableEntry?: MountedEquipment, jammed = false) { return { - rules: { computeEntryState: (candidate: MountedEquipment) => ({ isDamaged: candidate === unavailableEntry || candidate.committedDestroyed(), isDisabled: false, hitMod: 0 }) }, + rules: createTestEquipmentRules({ + getEquipmentStatus: (candidate: MountedEquipment) => ( + candidate === unavailableEntry || candidate.committedDestroyed() ? 'destroyed' : 'available' + ), + }), getCondition: (condition: string) => condition === 'jammed' && jammed } as never; } diff --git a/src/app/equipment-handlers/atm.handler.spec.ts b/src/app/equipment-handlers/atm.handler.spec.ts index 62f10debf..7967bcaa8 100644 --- a/src/app/equipment-handlers/atm.handler.spec.ts +++ b/src/app/equipment-handlers/atm.handler.spec.ts @@ -5,11 +5,15 @@ import { AmmoMunitionFlag } from '../models/ammo-munition-flags.type'; import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { AtmHandler } from './atm.handler'; function owner() { - return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), rules: { computeEntryState: () => ({ isDamaged: false, isDisabled: false, hitMod: 0 }) } } as never; + return { + setInventoryEntry: jasmine.createSpy('setInventoryEntry'), + rules: createTestEquipmentRules(), + } as never; } function weapon(): MountedEquipment { diff --git a/src/app/equipment-handlers/bombast-laser.handler.spec.ts b/src/app/equipment-handlers/bombast-laser.handler.spec.ts index a946de132..c9a8fc8e7 100644 --- a/src/app/equipment-handlers/bombast-laser.handler.spec.ts +++ b/src/app/equipment-handlers/bombast-laser.handler.spec.ts @@ -7,6 +7,7 @@ import { MiscEquipment, WeaponEquipment, type WeaponDamage } from '../models/equ 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 { createTestEquipmentRules } from '../testing/unit-test-helpers'; import { EquipmentInteractionRegistry, type HandlerContext } from '../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { @@ -29,13 +30,11 @@ function owner(gameRules: CBTGameRules = CORE_2026_GAME_RULES) { gameRules, setInventoryEntry: jasmine.createSpy('setInventoryEntry'), isEquipmentActionUnavailable: jasmine.createSpy('isEquipmentActionUnavailable').and.returnValue(false), - rules: { - computeEntryState: (entry: MountedEquipment) => ({ - isDamaged: entry.committedDestroyed(), - isDisabled: false, - hitMod: 0 - }) - } + rules: createTestEquipmentRules({ + getEquipmentStatus: (entry: MountedEquipment) => ( + entry.committedDestroyed() ? 'destroyed' : 'available' + ) + }) } as never; } diff --git a/src/app/equipment-handlers/c3-emergency-master.handler.spec.ts b/src/app/equipment-handlers/c3-emergency-master.handler.spec.ts index 816a70f36..41b800c53 100644 --- a/src/app/equipment-handlers/c3-emergency-master.handler.spec.ts +++ b/src/app/equipment-handlers/c3-emergency-master.handler.spec.ts @@ -12,6 +12,7 @@ import { type C3EmergencyMasterStatus, } from '../models/c3-emergency-master.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { C3EmergencyMasterHandler, C3EM_TOGGLE_CHOICE_VALUE } from './c3-emergency-master.handler'; @@ -21,7 +22,11 @@ function fixture(initialStatus: C3EmergencyMasterStatus = 'dormant') { const owner = { id: 'emergency-unit', readOnly: () => false, - rules: { computeEntryState: (entry: MountedEquipment) => ({ isDamaged: entry.committedDestroyed(), isDisabled: false, hitMod: 0 }) }, + rules: createTestEquipmentRules({ + getEquipmentStatus: (entry: MountedEquipment) => ( + entry.committedDestroyed() ? 'destroyed' : 'available' + ), + }), getInventory: () => [equipment], setInventoryEntry: jasmine.createSpy('setInventoryEntry'), getNotificationDisplayName: () => 'Emergency Unit', diff --git a/src/app/equipment-handlers/disabled-equipment.handler.spec.ts b/src/app/equipment-handlers/disabled-equipment.handler.spec.ts index cf409a853..3a4428e1e 100644 --- a/src/app/equipment-handlers/disabled-equipment.handler.spec.ts +++ b/src/app/equipment-handlers/disabled-equipment.handler.spec.ts @@ -6,13 +6,22 @@ import { EquipmentFlag } from '../models/equipment-flags.type'; import type { Equipment } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { ENTRY_DISABLED_STATE_KEY } from '../models/rules/unit-type-rules'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { DisabledEquipmentHandler, isEquipmentDisabledByFailure } from './disabled-equipment.handler'; function owner() { return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: { computeEntryState: (entry: MountedEquipment) => ({ isDamaged: entry.committedDestroyed(), isDisabled: isEquipmentDisabledByFailure(entry), hitMod: 0 }) } + rules: createTestEquipmentRules({ + getEquipmentStatus: (entry: MountedEquipment) => ( + entry.committedDestroyed() + ? 'destroyed' + : isEquipmentDisabledByFailure(entry) + ? 'disabled' + : 'available' + ), + }) } as never; } @@ -56,12 +65,12 @@ describe('DisabledEquipmentHandler', () => { expect(mounted.states.get(ENTRY_DISABLED_STATE_KEY)).toBe('true'); expect(mounted.owner.setInventoryEntry).toHaveBeenCalledWith(mounted); - expect(mounted.owner.rules.computeEntryState(mounted).isDisabled).toBeTrue(); + expect(mounted.owner.rules.getEquipmentStatus(mounted)).toBe('disabled'); handler.handleSelection(mounted, handler.getChoices(mounted, context)[0], context); expect(mounted.states.has(ENTRY_DISABLED_STATE_KEY)).toBeFalse(); - expect(mounted.owner.rules.computeEntryState(mounted).isDisabled).toBeFalse(); + expect(mounted.owner.rules.getEquipmentStatus(mounted)).toBe('available'); }); it('keeps the toggle available while the entry is disabled by this handler', () => { diff --git a/src/app/equipment-handlers/hag.handler.spec.ts b/src/app/equipment-handlers/hag.handler.spec.ts index 9807b2686..de6fae902 100644 --- a/src/app/equipment-handlers/hag.handler.spec.ts +++ b/src/app/equipment-handlers/hag.handler.spec.ts @@ -5,6 +5,7 @@ import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; import type { WeaponType } from '../models/weapon-types.model'; import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { HAG_FLAK_MODE, HAG_STANDARD_MODE, HagHandler, selectedHagMode } from './hag.handler'; @@ -13,9 +14,7 @@ function owner() { return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), isEquipmentActionUnavailable: jasmine.createSpy('isEquipmentActionUnavailable').and.returnValue(false), - rules: { - computeEntryState: () => ({ isDamaged: false, isDisabled: false, hitMod: 0 }) - } + rules: createTestEquipmentRules() } as never; } diff --git a/src/app/equipment-handlers/laser-insulator.handler.spec.ts b/src/app/equipment-handlers/laser-insulator.handler.spec.ts index b88e954c3..276e3e45e 100644 --- a/src/app/equipment-handlers/laser-insulator.handler.spec.ts +++ b/src/app/equipment-handlers/laser-insulator.handler.spec.ts @@ -4,12 +4,17 @@ import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { LaserInsulatorHandler } from './laser-insulator.handler'; function owner(unavailableEntry?: MountedEquipment) { return { - rules: { computeEntryState: (candidate: MountedEquipment) => ({ isDamaged: candidate === unavailableEntry || candidate.committedDestroyed(), isDisabled: false, hitMod: 0 }) } + rules: createTestEquipmentRules({ + getEquipmentStatus: (candidate: MountedEquipment) => ( + candidate === unavailableEntry || candidate.committedDestroyed() ? 'destroyed' : 'available' + ), + }) } as never; } diff --git a/src/app/equipment-handlers/masc.handler.spec.ts b/src/app/equipment-handlers/masc.handler.spec.ts index 2eabf94df..7ae3466f3 100644 --- a/src/app/equipment-handlers/masc.handler.spec.ts +++ b/src/app/equipment-handlers/masc.handler.spec.ts @@ -7,6 +7,7 @@ import { MiscEquipment } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; import { ENTRY_DISABLED_STATE_KEY } from '../models/rules/unit-type-rules'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { MASC_ACTIVE_STATE_KEY, @@ -24,9 +25,11 @@ function owner( ...turnStateOverrides, }; return { - rules: { - computeEntryState: (entry: MountedEquipment) => ({ isDamaged: entry.committedDestroyed(), isDisabled: false, hitMod: 0 }), - }, + rules: createTestEquipmentRules({ + getEquipmentStatus: (entry: MountedEquipment) => ( + entry.committedDestroyed() ? 'destroyed' : 'available' + ), + }), gameRules, getNotificationDisplayName: () => 'Atlas AS7-D (Natasha Kerensky)', setInventoryEntry: jasmine.createSpy('setInventoryEntry'), diff --git a/src/app/equipment-handlers/mml.handler.spec.ts b/src/app/equipment-handlers/mml.handler.spec.ts index e50a77b8e..ceaa7f9fc 100644 --- a/src/app/equipment-handlers/mml.handler.spec.ts +++ b/src/app/equipment-handlers/mml.handler.spec.ts @@ -5,11 +5,15 @@ import { EquipmentFlag } from '../models/equipment-flags.type'; import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { MmlHandler } from './mml.handler'; function owner() { - return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), rules: { computeEntryState: () => ({ isDamaged: false, isDisabled: false, hitMod: 0 }) } } as never; + return { + setInventoryEntry: jasmine.createSpy('setInventoryEntry'), + rules: createTestEquipmentRules(), + } as never; } function weapon(): MountedEquipment { diff --git a/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts b/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts index f5a14588d..44410b567 100644 --- a/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts +++ b/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts @@ -7,6 +7,7 @@ import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; import { EquipmentRegistry } from '../models/equipment-lookup'; import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import { EquipmentInteractionRegistry, type HandlerContext } from '../services/equipment-interaction-registry.service'; import { resolveInventoryControlDamageText } from '../utils/inventory-control-damage.util'; import { @@ -20,13 +21,11 @@ import { function setup(destroyed = false, compatible = true) { const owner = { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: { - computeEntryState: (entry: MountedEquipment) => ({ - isDamaged: entry.committedDestroyed(), - isDisabled: false, - hitMod: 0 - }) - } + rules: createTestEquipmentRules({ + getEquipmentStatus: (entry: MountedEquipment) => ( + entry.committedDestroyed() ? 'destroyed' : 'available' + ) + }) } as unknown as CBTForceUnit; const capacitor = new MountedEquipment({ owner, diff --git a/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts b/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts index 5cac4be9e..e4d05d625 100644 --- a/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts +++ b/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts @@ -4,12 +4,20 @@ import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { RISC_LASER_PULSE_MODE, RISC_LASER_STANDARD_MODE, RiscLaserPulseModuleHandler } from './risc-laser-pulse-module.handler'; function owner() { - return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), rules: { computeEntryState: (entry: MountedEquipment) => ({ isDamaged: entry.committedDestroyed(), isDisabled: false, hitMod: 0 }) } } as never; + return { + setInventoryEntry: jasmine.createSpy('setInventoryEntry'), + rules: createTestEquipmentRules({ + getEquipmentStatus: (entry: MountedEquipment) => ( + entry.committedDestroyed() ? 'destroyed' : 'available' + ), + }), + } as never; } function laser(module: MountedEquipment, states = new Map()): MountedEquipment { diff --git a/src/app/equipment-handlers/stealth.handler.spec.ts b/src/app/equipment-handlers/stealth.handler.spec.ts index 829eacb2e..81005de95 100644 --- a/src/app/equipment-handlers/stealth.handler.spec.ts +++ b/src/app/equipment-handlers/stealth.handler.spec.ts @@ -5,13 +5,14 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import type { Equipment } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { StealthHandler } from './stealth.handler'; function equipment(flag: 'F_STEALTH' | 'F_CHAMELEON_SHIELD' | 'F_ECM'): MountedEquipment { const owner = { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: { computeEntryState: () => ({ isDamaged: false, isDisabled: false, hitMod: 0 }) }, + rules: createTestEquipmentRules(), } as never; return new MountedEquipment({ owner, diff --git a/src/app/equipment-handlers/uacjamming.handler.spec.ts b/src/app/equipment-handlers/uacjamming.handler.spec.ts index 43984dfe0..e7861dbab 100644 --- a/src/app/equipment-handlers/uacjamming.handler.spec.ts +++ b/src/app/equipment-handlers/uacjamming.handler.spec.ts @@ -6,6 +6,7 @@ import { WeaponEquipment, type AmmoType } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../models/rules/unit-type-rules'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import { isEquipmentDisabledByFailure } from './disabled-equipment.handler'; import { UACJammingHandler } from './uacjamming.handler'; @@ -14,9 +15,15 @@ function owner(gameRules: CBTGameRules = CORE_2026_GAME_RULES) { return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), gameRules, - rules: { - computeEntryState: (entry: MountedEquipment) => ({ isDamaged: entry.committedDestroyed(), isDisabled: isEquipmentDisabledByFailure(entry), hitMod: 0 }) - } + rules: createTestEquipmentRules({ + getEquipmentStatus: (entry: MountedEquipment) => ( + entry.committedDestroyed() + ? 'destroyed' + : isEquipmentDisabledByFailure(entry) + ? 'disabled' + : 'available' + ) + }) } as never; } diff --git a/src/app/equipment-handlers/vibroblade.handler.spec.ts b/src/app/equipment-handlers/vibroblade.handler.spec.ts index 61599086d..439662d5c 100644 --- a/src/app/equipment-handlers/vibroblade.handler.spec.ts +++ b/src/app/equipment-handlers/vibroblade.handler.spec.ts @@ -5,6 +5,7 @@ import { Equipment, type EquipmentRawData } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { HandlerContext } from '../services/equipment-interaction-registry.service'; import type { InventoryControlDisplayData } from '../utils/inventory-control.util'; import { getVibrobladeBaseDamage, VIBROBLADE_MODE_STATE, VIBROBLADE_OFF_MODE, VIBROBLADE_ON_MODE, VibrobladeHandler } from './vibroblade.handler'; @@ -25,13 +26,11 @@ function setup(size: 'SMALL' | 'MEDIUM' | 'LARGE' = 'SMALL', destroyed = false, const owner = { getUnit: () => ({ tons }), setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: { - computeEntryState: (entry: MountedEquipment) => ({ - isDamaged: entry.committedDestroyed(), - isDisabled: false, - hitMod: 0, - }), - }, + rules: createTestEquipmentRules({ + getEquipmentStatus: (entry: MountedEquipment) => ( + entry.committedDestroyed() ? 'destroyed' : 'available' + ), + }), } as unknown as CBTForceUnit; const equipment = new Equipment({ id: `${size}Vibroblade`, diff --git a/src/app/models/cbt-force-unit-c3.spec.ts b/src/app/models/cbt-force-unit-c3.spec.ts index 0bd592357..6475881a7 100644 --- a/src/app/models/cbt-force-unit-c3.spec.ts +++ b/src/app/models/cbt-force-unit-c3.spec.ts @@ -9,6 +9,7 @@ import { MountedEquipment } from './mounted-equipment.model'; import type { SerializedC3NetworkGroup } from './force-serialization'; import type { InventoryControlRuntimeTarget } from './inventory-control-runtime-state.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES } from './rules/game-rules'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; const TARGET: InventoryControlRuntimeTarget = { id: 'A', @@ -51,7 +52,7 @@ function c3BadgeUnit( shutdown: { value: false, writable: true, configurable: true }, getUnit: { value: () => ({ comp: [] }), configurable: true }, rules: { - value: { computeEntryState: () => ({ isDamaged: false, isDisabled: false, hitMod: 0 }) }, + value: createTestEquipmentRules(), configurable: true, }, getInventory: { value: () => inventory, configurable: true }, diff --git a/src/app/models/mounted-equipment.model.spec.ts b/src/app/models/mounted-equipment.model.spec.ts index 01e2b4766..5b0faa773 100644 --- a/src/app/models/mounted-equipment.model.spec.ts +++ b/src/app/models/mounted-equipment.model.spec.ts @@ -4,6 +4,7 @@ import { AmmoEquipment, MiscEquipment, WeaponEquipment } from './equipment.model'; import { getMountedOneShotConsumed, MountedAmmo, MountedEquipment, MountedWeapon } from './mounted-equipment.model'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; describe('MountedAmmo capacity baseline', () => { const ammoEquipment = new AmmoEquipment({ @@ -152,9 +153,7 @@ describe('MountedEquipment physical classification', () => { describe('MountedEquipment action availability', () => { it('delegates action availability to its owning unit', () => { const owner = jasmine.createSpyObj('CBTForceUnit', ['isEquipmentActionUnavailable']); - owner.rules = { - computeEntryState: () => ({ isDamaged: false, isDisabled: false, hitMod: 0 }) - }; + owner.rules = createTestEquipmentRules(); const entry = new MountedEquipment({ owner, id: 'laser', name: 'Laser' }); owner.isEquipmentActionUnavailable.and.returnValue(false); @@ -170,9 +169,7 @@ describe('MountedEquipment action availability', () => { it('is action-unavailable when structurally unavailable without consulting its owner', () => { const owner = jasmine.createSpyObj('CBTForceUnit', ['isEquipmentActionUnavailable']); - owner.rules = { - computeEntryState: () => ({ isDamaged: true, isDisabled: false, hitMod: 0 }) - }; + owner.rules = createTestEquipmentRules({ getEquipmentStatus: () => 'destroyed' }); const entry = new MountedEquipment({ owner, id: 'laser', name: 'Laser' }); expect(entry.isUnavailable()).toBeTrue(); diff --git a/src/app/models/mounted-equipment.model.ts b/src/app/models/mounted-equipment.model.ts index fba166d0c..769d55d04 100644 --- a/src/app/models/mounted-equipment.model.ts +++ b/src/app/models/mounted-equipment.model.ts @@ -2,13 +2,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { computed, signal, type Signal, type WritableSignal } from '@angular/core'; +import { signal, type WritableSignal } from '@angular/core'; import type { CBTForceUnit } from './cbt-force-unit.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'; export interface MountedEquipmentInit { @@ -63,7 +62,6 @@ export class MountedEquipment { readonly originalTotalAmmo?: number; consumed?: number; intrinsicOneShotAmmo?: boolean; - readonly ruleState: Signal; get linkedWith(): readonly MountedEquipment[] | null | undefined { return this.linkedEquipment; @@ -203,7 +201,6 @@ export class MountedEquipment { this.originalTotalAmmo = data.originalTotalAmmo ?? data.totalAmmo; this.consumed = data.consumed; this.intrinsicOneShotAmmo = data.intrinsicOneShotAmmo; - this.ruleState = computed(() => this.owner.rules.computeEntryState(this)); } static from(entry: MountedEquipment | MountedEquipmentInit): MountedEquipment { @@ -256,16 +253,15 @@ export class MountedEquipment { } isDestroyed(): boolean { - return this.ruleState().isDamaged; + return this.owner.rules.getEquipmentStatus(this) === 'destroyed'; } isDisabled(): boolean { - return this.ruleState().isDisabled; + return this.owner.rules.getEquipmentStatus(this) === 'disabled'; } isUnavailable(): boolean { - const state = this.ruleState(); - return state.isDamaged || state.isDisabled; + return this.owner.rules.getEquipmentStatus(this) !== 'available'; } /** Whether this mount is structurally unavailable or temporarily unable to act. */ diff --git a/src/app/models/rules/aero-rules.spec.ts b/src/app/models/rules/aero-rules.spec.ts index 5ca11ab72..cb43c63cf 100644 --- a/src/app/models/rules/aero-rules.spec.ts +++ b/src/app/models/rules/aero-rules.spec.ts @@ -27,41 +27,37 @@ function createHarness(heat: number, physical = false): { rules: AeroRules; entr describe('AeroRules', () => { it('does not apply a fire modifier below the first heat threshold', () => { const { rules, entry } = createHarness(7); + const state = rules.getEquipmentToHit(entry); - expect(rules.computeEntryState(entry)).toEqual(jasmine.objectContaining({ - hitMod: 0, - hitModifierBreakdown: [] - })); + expect(state.modifier).toBe(0); + expect(state.modifiers).toEqual([]); }); it('includes heat as a named weakened entry-state modifier', () => { const { rules, entry } = createHarness(8); + const state = rules.getEquipmentToHit(entry); - expect(rules.computeEntryState(entry)).toEqual(jasmine.objectContaining({ - hitMod: 1, - hitModifierBreakdown: [ - { label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' } - ] - })); + expect(state.modifier).toBe(1); + expect(state.modifiers).toEqual([ + { label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' } + ]); }); it('uses the cumulative modifier at higher heat thresholds', () => { const { rules, entry } = createHarness(24); + const state = rules.getEquipmentToHit(entry); - expect(rules.computeEntryState(entry)).toEqual(jasmine.objectContaining({ - hitMod: 4, - hitModifierBreakdown: [ - { label: 'Heat - Fire Modifier', modifier: 4, weakened: true, kind: 'heat' } - ] - })); + expect(state.modifier).toBe(4); + expect(state.modifiers).toEqual([ + { label: 'Heat - Fire Modifier', modifier: 4, weakened: true, kind: 'heat' } + ]); }); it('does not apply heat fire modifiers to physical attacks', () => { const { rules, entry } = createHarness(24, true); + const state = rules.getEquipmentToHit(entry); - expect(rules.computeEntryState(entry)).toEqual(jasmine.objectContaining({ - hitMod: 0, - hitModifierBreakdown: [] - })); + expect(state.modifier).toBe(0); + expect(state.modifiers).toEqual([]); }); }); \ No newline at end of file diff --git a/src/app/models/rules/game-rules.spec.ts b/src/app/models/rules/game-rules.spec.ts index d0cd440e3..639c28254 100644 --- a/src/app/models/rules/game-rules.spec.ts +++ b/src/app/models/rules/game-rules.spec.ts @@ -6,6 +6,7 @@ import { EquipmentFlag } from '../equipment-flags.type'; import { EquipmentRegistry } from '../equipment-lookup'; import { AmmoEquipment, MiscEquipment, WeaponEquipment, type Equipment } from '../equipment.model'; import { MountedEquipment } from '../mounted-equipment.model'; +import { createTestEquipmentRules } from '../../testing/unit-test-helpers'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, separateHeatFireModifier } from './game-rules'; let entryId = 0; @@ -13,8 +14,11 @@ let entryId = 0; function owner() { return { rules: { - computeEntryState: (candidate: MountedEquipment) => ({ isDamaged: candidate.committedDestroyed(), isDisabled: false, hitMod: 0 }), - computeAllEntryStates: () => new Map(), + ...createTestEquipmentRules({ + getEquipmentStatus: (candidate: MountedEquipment) => ( + candidate.committedDestroyed() ? 'destroyed' : 'available' + ), + }), heatDissipation: () => null } } as never; diff --git a/src/app/models/rules/infantry-rules.spec.ts b/src/app/models/rules/infantry-rules.spec.ts index 3e7d92321..e6a69a189 100644 --- a/src/app/models/rules/infantry-rules.spec.ts +++ b/src/app/models/rules/infantry-rules.spec.ts @@ -43,7 +43,7 @@ describe('InfantryRules', () => { expect(rules.getFieldGunComponent(entries[0])).toBe(fieldGunComponent); expect(rules.getFieldGunFunctionalCount(fieldGunComponent)).toBe(2); - expect(entries.map(entry => rules.computeEntryState(entry).isDisabled)).toEqual([false, false, true]); + expect(entries.map(entry => rules.getEquipmentStatus(entry))).toEqual(['available', 'available', 'disabled']); }); it('does not mutate derived intrinsic ammo while evaluating Battle Armor destruction', () => { diff --git a/src/app/models/rules/infantry-rules.ts b/src/app/models/rules/infantry-rules.ts index 80cbfbb4a..bba0b5dd9 100644 --- a/src/app/models/rules/infantry-rules.ts +++ b/src/app/models/rules/infantry-rules.ts @@ -10,7 +10,7 @@ import type { MotiveModes } from '../motiveModes.model'; import { getTargetUnitTypeModifier } from '../target-number-calculator.model'; import type { TurnState } from '../turn-state.model'; import type { UnitComponent } from '../units.model'; -import type { MountedEquipmentRuleState } from './unit-type-rules'; +import type { MountedEquipmentStatus } from './unit-type-rules'; import { UnitTypeRulesBase, type UnitModifierBreakdownEntry } from './unit-type-rules'; export const FIELD_GUN_LOCATION = 'FGUN'; @@ -88,12 +88,10 @@ export class InfantryRules extends UnitTypeRulesBase { return null; } - override computeEntryState(entry: MountedEquipment): MountedEquipmentRuleState { - const state = super.computeEntryState(entry); - return { - ...state, - isDisabled: state.isDisabled || this.isInfantryFieldGunEntryDisabled(entry) - }; + override getEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus { + const availability = super.getEquipmentStatus(entry); + if (availability !== 'available') return availability; + return this.isInfantryFieldGunEntryDisabled(entry) ? 'disabled' : 'available'; } isInfantryFieldGunEntryDisabled(entry: MountedEquipment): boolean { diff --git a/src/app/models/rules/mek-rules.spec.ts b/src/app/models/rules/mek-rules.spec.ts index ad6ae571f..91e2f7149 100644 --- a/src/app/models/rules/mek-rules.spec.ts +++ b/src/app/models/rules/mek-rules.spec.ts @@ -16,7 +16,7 @@ import { DataService } from '../../services/data.service'; import { EquipmentInteractionRegistryService } from '../../services/equipment-interaction-registry.service'; import { UnitInitializerService } from '../../services/unit-initializer.service'; import { createEmptyUnit } from '../../testing/unit-test-helpers'; -import type { MountedEquipmentRuleState } from './unit-type-rules'; +import { type MountedEquipmentToHit } from './unit-type-rules'; import { MekRules } from './mek-rules'; import { MascHandler, MASC_ACTIVE_STATE_KEY } from '../../equipment-handlers/masc.handler'; import { HAG_FLAK_MODE, HAG_STANDARD_MODE, HagHandler } from '../../equipment-handlers/hag.handler'; @@ -24,6 +24,7 @@ import { INVENTORY_CONTROL_MODE_STATE } from '../../utils/inventory-control.util import { OptionsService } from '../../services/options.service'; import { TWMekRules } from './tw-rules'; import { VIBROBLADE_MODE_STATE, VIBROBLADE_ON_MODE, VibrobladeHandler } from '../../equipment-handlers/vibroblade.handler'; +import { PPC_CAPACITOR_CHARGED_STATE, PPC_CAPACITOR_STATE_KEY, PpcCapacitorHandler } from '../../equipment-handlers/ppc-capacitor.handler'; import { EquipmentFlag } from '../equipment-flags.type'; class TestCBTForce extends CBTForce { @@ -36,8 +37,8 @@ let unitInitializer: UnitInitializerService; let injector: Injector; let optionsService: OptionsService; -function hasWeakenedHitModifier(state: MountedEquipmentRuleState): boolean { - return state.hitModifierBreakdown?.some(modifier => modifier.weakened === true) ?? false; +function hasWeakenedHitModifier(state: MountedEquipmentToHit): boolean { + return state.modifiers.some(modifier => modifier.weakened === true); } function createRulesHarness(options: { @@ -402,17 +403,52 @@ describe('MekRules', () => { const activeForceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer', false)] }); const destroyedForceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer')] }); - const activeState = activeForceUnit.rules.computeEntryState(directFireWeaponEntry(activeForceUnit)); - const destroyedState = destroyedForceUnit.rules.computeEntryState(directFireWeaponEntry(destroyedForceUnit)); - const ineligibleState = destroyedForceUnit.rules.computeEntryState(directFireWeaponEntry(destroyedForceUnit, ['F_TASER'])); - expect(activeState).toEqual(jasmine.objectContaining({ hitMod: -1, isDamaged: false })); - expect(destroyedState).toEqual(jasmine.objectContaining({ hitMod: 0, isDamaged: false })); - expect(ineligibleState).toEqual(jasmine.objectContaining({ hitMod: 0, isDamaged: false })); + const activeState = activeForceUnit.rules.getEquipmentToHit(directFireWeaponEntry(activeForceUnit)); + const destroyedState = destroyedForceUnit.rules.getEquipmentToHit(directFireWeaponEntry(destroyedForceUnit)); + const ineligibleState = destroyedForceUnit.rules.getEquipmentToHit(directFireWeaponEntry(destroyedForceUnit, ['F_TASER'])); + expect((activeState).modifier).toBe(-1); + expect((destroyedState).modifier).toBe(0); + expect((ineligibleState).modifier).toBe(0); expect(hasWeakenedHitModifier(activeState)).toBeFalse(); expect(hasWeakenedHitModifier(destroyedState)).toBeTrue(); expect(hasWeakenedHitModifier(ineligibleState)).toBeFalse(); }); + it('does not cycle while resolving a charged PPC capacitor weapon state', () => { + TestBed.inject(EquipmentInteractionRegistryService).getRegistry().register(new PpcCapacitorHandler()); + const forceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer', false)] }); + const capacitor = new MountedEquipment({ + owner: forceUnit, + id: 'PPC Capacitor', + name: 'PPC Capacitor', + equipment: new Equipment({ + id: 'PPC Capacitor', + name: 'PPC Capacitor', + type: 'misc', + flags: ['F_WEAPON_ENHANCEMENT', 'F_PPC_CAPACITOR'], + }), + states: new Map([[PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE]]), + }); + const weapon = new MountedWeapon({ + owner: forceUnit, + id: 'Light PPC', + name: 'Light PPC', + equipment: new WeaponEquipment({ + id: 'Light PPC', + name: 'Light PPC', + type: 'weapon', + flags: ['F_PPC', 'F_DIRECT_FIRE', 'F_ENERGY', 'F_PPC_CAPACITOR_COMPATIBLE'], + weapon: { damage: 5, ranges: [3, 6, 9, 12], ammoType: 'NA' }, + }), + }); + weapon.linkedWith = [capacitor]; + + expect(() => weapon.owner.rules.getEquipmentToHit(weapon)).not.toThrow(); + expect(weapon.owner.rules.getEquipmentToHit(weapon).modifiers).toContain( + jasmine.objectContaining({ label: 'Targeting Computer', modifier: -1 }) + ); + }); + it('stacks a targeting computer with each range-specific VSP laser modifier', () => { const activeForceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer', false)] }); const destroyedForceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer')] }); @@ -423,27 +459,27 @@ describe('MekRules', () => { ]; const activeEntry = mediumVspLaserEntry(activeForceUnit); - const activeState = activeForceUnit.rules.computeEntryState(activeEntry); + const activeState = activeForceUnit.rules.getEquipmentToHit(activeEntry); expect(activeEntry.parent).toBeInstanceOf(MountedWeapon); expect((activeEntry.parent as MountedWeapon).getWeaponTypes()).toContain('P'); - expect(activeState).toEqual(jasmine.objectContaining({ hitMod: -1 })); + expect((activeState).modifier).toBe(-1); for (const expected of ranges) { expect(activeForceUnit.gameRules.resolveToHit({ subject: activeEntry, range: expected.range, - stateModifier: activeState.hitMod, - stateModifierBreakdown: activeState.hitModifierBreakdown, + stateModifier: (activeState).modifier, + stateModifierBreakdown: activeState.modifiers, }).value).withContext(`functional targeting computer at ${expected.range} range`).toBe(expected.value); } const destroyedEntry = mediumVspLaserEntry(destroyedForceUnit); - const destroyedState = destroyedForceUnit.rules.computeEntryState(destroyedEntry); - expect(destroyedState).toEqual(jasmine.objectContaining({ hitMod: 0 })); + const destroyedState = destroyedForceUnit.rules.getEquipmentToHit(destroyedEntry); + expect((destroyedState).modifier).toBe(0); const destroyedResolution = destroyedForceUnit.gameRules.resolveToHit({ subject: destroyedEntry, range: 'short', - stateModifier: destroyedState.hitMod, - stateModifierBreakdown: destroyedState.hitModifierBreakdown, + stateModifier: (destroyedState).modifier, + stateModifierBreakdown: destroyedState.modifiers, }); expect(destroyedResolution.value).toBe(-3); expect(destroyedResolution.weakened).toBeTrue(); @@ -466,12 +502,12 @@ describe('MekRules', () => { const forceUnit = createForceUnitHarness({ critSlots }); const entry = hagWeaponEntry(forceUnit, scenario.mode); const rules = forceUnit.getInventoryControlRules(); - const state = forceUnit.rules.computeEntryState(entry); + const state = forceUnit.rules.getEquipmentToHit(entry); const effectiveTypes = rules.applyWeaponTypes?.(entry, new Set(entry.getWeaponTypes())) ?? new Set(entry.getWeaponTypes()); const resolution = forceUnit.gameRules.resolveToHit({ subject: entry, - stateModifier: state.hitMod, - stateModifierBreakdown: state.hitModifierBreakdown, + stateModifier: (state).modifier, + stateModifierBreakdown: state.modifiers, adjustments: rules.resolveToHitAdjustments?.(entry) }); @@ -505,14 +541,14 @@ describe('MekRules', () => { locations: new Set(['LA']), }); - const activePunch = activeForceUnit.rules.computeEntryState(punch(activeForceUnit)); - const destroyedPunch = destroyedForceUnit.rules.computeEntryState(punch(destroyedForceUnit)); - const activeSword = activeForceUnit.rules.computeEntryState(sword(activeForceUnit)); - const destroyedSword = destroyedForceUnit.rules.computeEntryState(sword(destroyedForceUnit)); - expect(activePunch.hitMod).toBe(-1); - expect(destroyedPunch.hitMod).toBe(0); - expect(activeSword.hitMod).toBe(-1); - expect(destroyedSword.hitMod).toBe(0); + const activePunch = activeForceUnit.rules.getEquipmentToHit(punch(activeForceUnit)); + const destroyedPunch = destroyedForceUnit.rules.getEquipmentToHit(punch(destroyedForceUnit)); + const activeSword = activeForceUnit.rules.getEquipmentToHit(sword(activeForceUnit)); + const destroyedSword = destroyedForceUnit.rules.getEquipmentToHit(sword(destroyedForceUnit)); + expect((activePunch).modifier).toBe(-1); + expect((destroyedPunch).modifier).toBe(0); + expect((activeSword).modifier).toBe(-1); + expect((destroyedSword).modifier).toBe(0); expect(hasWeakenedHitModifier(activePunch)).toBeFalse(); expect(hasWeakenedHitModifier(destroyedPunch)).toBeTrue(); expect(hasWeakenedHitModifier(activeSword)).toBeFalse(); @@ -536,9 +572,10 @@ describe('MekRules', () => { intrinsicPhysicalAttack: true, }); - expect(forceUnit.rules.computeEntryState(punch)).toEqual(jasmine.objectContaining({ - hitMod: 5, - hitModifierBreakdown: [ + const punchState = forceUnit.rules.getEquipmentToHit(punch); + expect((punchState).modifier).toBe(5); + expect(punchState).toEqual(jasmine.objectContaining({ + modifiers: [ { label: 'Hand Actuator Destroyed (LA)', modifier: 1, weakened: true }, { label: 'Upper Arm Actuator Destroyed (LA)', modifier: 2, weakened: true }, { label: 'Lower Arm Actuator Destroyed (LA)', modifier: 2, weakened: true } @@ -576,17 +613,16 @@ describe('MekRules', () => { internalLocations: ['LA', 'RA', 'LL', 'RL'], }); const punch = punchEntry(forceUnit); - const state = forceUnit.rules.computeEntryState(punch); + const state = forceUnit.rules.getEquipmentToHit(punch); const resolution = forceUnit.gameRules.resolveToHit({ subject: punch, - stateModifier: state.hitMod, - stateModifierBreakdown: state.hitModifierBreakdown, + stateModifier: (state).modifier, + stateModifierBreakdown: state.modifiers, }); const rulesBase = rulesId === 'core2026' ? -1 : 0; expect(state).withContext(`${rulesId}: ${scenario.label}`).toEqual(jasmine.objectContaining({ - hitMod: scenario.hitMod, - hitModifierBreakdown: scenario.breakdown, + modifiers: scenario.breakdown, })); expect(resolution.value).withContext(`${rulesId}: ${scenario.label} resolved modifier`) .toBe(rulesBase + scenario.hitMod); @@ -607,8 +643,8 @@ describe('MekRules', () => { expect((missingLowerArmUnit.rules as MekRules).computeMeleeDamage(3, 'punch', 'LA')).toEqual({ damage: 3, maxDamage: 3 }); expect((destroyedLowerArmUnit.rules as MekRules).computeMeleeDamage(6, 'punch', 'LA')).toEqual({ damage: 3, maxDamage: 3 }); - expect(destroyedLowerArmUnit.rules.computeEntryState(punchEntry(destroyedLowerArmUnit))) - .toEqual(jasmine.objectContaining({ hitMod: 2 })); + expect((destroyedLowerArmUnit.rules.getEquipmentToHit(punchEntry(destroyedLowerArmUnit))).modifier) + .toBe(2); }); it('identifies shoulder and paired AES modifiers for push attacks', () => { @@ -622,9 +658,10 @@ describe('MekRules', () => { }); const push = new MountedEquipment({ owner: forceUnit, id: 'push', name: 'push', intrinsicPhysicalAttack: true }); - expect(forceUnit.rules.computeEntryState(push)).toEqual(jasmine.objectContaining({ - hitMod: 1, - hitModifierBreakdown: [ + const pushState = forceUnit.rules.getEquipmentToHit(push); + expect((pushState).modifier).toBe(1); + expect(pushState).toEqual(jasmine.objectContaining({ + modifiers: [ { label: 'Shoulder Destroyed (LA)', modifier: 2, weakened: true }, { label: 'Paired Arm AES', modifier: -1 } ] @@ -644,9 +681,10 @@ describe('MekRules', () => { }); const kick = new MountedEquipment({ owner: forceUnit, id: 'kick', name: 'kick', intrinsicPhysicalAttack: true }); - expect(forceUnit.rules.computeEntryState(kick)).toEqual(jasmine.objectContaining({ - hitMod: 4, - hitModifierBreakdown: [ + const kickState = forceUnit.rules.getEquipmentToHit(kick); + expect((kickState).modifier).toBe(4); + expect(kickState).toEqual(jasmine.objectContaining({ + modifiers: [ { label: 'Leg Actuators Destroyed ×2', modifier: 4, weakened: true }, { label: 'Foot Actuator Destroyed', modifier: 1, weakened: true }, { label: 'Leg AES', modifier: -1 } @@ -671,9 +709,10 @@ describe('MekRules', () => { locations: new Set(['LA']), }); - expect(forceUnit.rules.computeEntryState(sword)).toEqual(jasmine.objectContaining({ - hitMod: 3, - hitModifierBreakdown: [ + const swordState = forceUnit.rules.getEquipmentToHit(sword); + expect((swordState).modifier).toBe(3); + expect(swordState).toEqual(jasmine.objectContaining({ + modifiers: [ { label: 'Upper Arm Actuator Destroyed (LA)', modifier: 2, weakened: true }, { label: 'Lower Arm Actuator Destroyed (LA)', modifier: 2, weakened: true }, { label: 'Arm AES (LA)', modifier: -1 } @@ -702,11 +741,11 @@ describe('MekRules', () => { intrinsicPhysicalAttack: true, }); - const clubState = forceUnit.rules.computeEntryState(physical('club')); - const pushState = forceUnit.rules.computeEntryState(physical('push')); - expect(clubState.hitMod).withContext(`${scenario.label} arm AES for club`).toBe(scenario.club.hitMod); + const clubState = forceUnit.rules.getEquipmentToHit(physical('club')); + const pushState = forceUnit.rules.getEquipmentToHit(physical('push')); + expect((clubState).modifier).withContext(`${scenario.label} arm AES for club`).toBe(scenario.club.hitMod); expect(hasWeakenedHitModifier(clubState)).withContext(`${scenario.label} arm AES for club`).toBe(scenario.club.weakened); - expect(pushState.hitMod).withContext(`${scenario.label} arm AES for push`).toBe(scenario.push.hitMod); + expect((pushState).modifier).withContext(`${scenario.label} arm AES for push`).toBe(scenario.push.hitMod); expect(hasWeakenedHitModifier(pushState)).withContext(`${scenario.label} arm AES for push`).toBe(scenario.push.weakened); } }); @@ -731,8 +770,8 @@ describe('MekRules', () => { intrinsicPhysicalAttack: true, }); - const state = forceUnit.rules.computeEntryState(kick); - expect(state.hitMod).withContext(`${scenario.label} leg AES`).toBe(scenario.expected.hitMod); + const state = forceUnit.rules.getEquipmentToHit(kick); + expect((state).modifier).withContext(`${scenario.label} leg AES`).toBe(scenario.expected.hitMod); expect(hasWeakenedHitModifier(state)).withContext(`${scenario.label} leg AES`).toBe(scenario.expected.weakened); } }); @@ -920,15 +959,13 @@ describe('MekRules', () => { const rules = forceUnit.rules as MekRules; expect(rules.getBaseGunnerySkill()).toBe(3); - expect(rules.computeEntryState(directFireWeaponEntry(forceUnit))).toEqual(jasmine.objectContaining({ - hitMod: 0, - hitModifierBreakdown: [], - })); + const rangedState = rules.getEquipmentToHit(directFireWeaponEntry(forceUnit)); + expect((rangedState).modifier).toBe(0); + expect(rangedState).toEqual(jasmine.objectContaining({ modifiers: [] })); expect(rules.getBasePilotingSkill()).toBe(5); - expect(rules.computeEntryState(punchEntry(forceUnit))).toEqual(jasmine.objectContaining({ - hitMod: -1, - hitModifierBreakdown: [{ label: 'Dedicated Pilot', modifier: -1 }], - })); + const punchState = rules.getEquipmentToHit(punchEntry(forceUnit)); + expect((punchState).modifier).toBe(-1); + expect(punchState).toEqual(jasmine.objectContaining({ modifiers: [{ label: 'Dedicated Pilot', modifier: -1 }] })); expect(rules.PSRTargetRoll()).toBe(4); }); @@ -941,9 +978,10 @@ describe('MekRules', () => { expect(rules.getBaseGunnerySkill()).toBe(5); const ranged = directFireWeaponEntry(forceUnit); - expect(rules.computeEntryState(ranged)).toEqual(jasmine.objectContaining({ - hitMod: 2, - hitModifierBreakdown: [{ label: 'Dedicated Gunnery Officer disabled', modifier: 2, weakened: true }], + const rangedState = rules.getEquipmentToHit(ranged); + expect((rangedState).modifier).toBe(2); + expect(rangedState).toEqual(jasmine.objectContaining({ + modifiers: [{ label: 'Dedicated Gunnery Officer disabled', modifier: 2, weakened: true }], })); expect(forceUnit.turnState().getAttackModifierBreakdown()).toEqual([]); }); @@ -964,9 +1002,10 @@ describe('MekRules', () => { const ranged = directFireWeaponEntry(forceUnit); expect(forceUnit.turnState().getAttackModifierBreakdown()).withContext(scenario.context).toEqual([]); - expect(forceUnit.rules.computeEntryState(ranged)).withContext(scenario.context).toEqual(jasmine.objectContaining({ - hitMod: scenario.modifier, - hitModifierBreakdown: [{ label: scenario.label, modifier: scenario.modifier, weakened: true }], + const rangedState = forceUnit.rules.getEquipmentToHit(ranged); + expect((rangedState).modifier).withContext(scenario.context).toBe(scenario.modifier); + expect(rangedState).withContext(scenario.context).toEqual(jasmine.objectContaining({ + modifiers: [{ label: scenario.label, modifier: scenario.modifier, weakened: true }], })); } }); @@ -979,9 +1018,10 @@ describe('MekRules', () => { const rules = forceUnit.rules as MekRules; expect(rules.getBasePilotingSkill()).toBe(6); - expect(rules.computeEntryState(punchEntry(forceUnit))).toEqual(jasmine.objectContaining({ - hitMod: 2, - hitModifierBreakdown: [{ label: 'Dedicated Pilot disabled', modifier: 2, weakened: true }], + const punchState = rules.getEquipmentToHit(punchEntry(forceUnit)); + expect((punchState).modifier).toBe(2); + expect(punchState).toEqual(jasmine.objectContaining({ + modifiers: [{ label: 'Dedicated Pilot disabled', modifier: 2, weakened: true }], })); expect(rules.PSRTargetRoll()).toBe(8); }); @@ -997,20 +1037,19 @@ describe('MekRules', () => { }); const ranged = new MountedEquipment({ owner: forceUnit, id: 'laser', name: 'Laser' }); - expect(forceUnit.rules.computeEntryState(punch)).toEqual(jasmine.objectContaining({ - hitMod: -1, - hitModifierBreakdown: [{ label: 'Dedicated Pilot', modifier: -1 }], - })); - expect(forceUnit.rules.computeEntryState(ranged)).toEqual(jasmine.objectContaining({ - hitMod: 0, - hitModifierBreakdown: [], - })); + const initialPunchState = forceUnit.rules.getEquipmentToHit(punch); + expect((initialPunchState).modifier).toBe(-1); + expect(initialPunchState).toEqual(jasmine.objectContaining({ modifiers: [{ label: 'Dedicated Pilot', modifier: -1 }] })); + const initialRangedState = forceUnit.rules.getEquipmentToHit(ranged); + expect((initialRangedState).modifier).toBe(0); + expect(initialRangedState).toEqual(jasmine.objectContaining({ modifiers: [] })); forceUnit.getCrewMember(0).setState('unconscious'); - expect(forceUnit.rules.computeEntryState(punch)).toEqual(jasmine.objectContaining({ - hitMod: 2, - hitModifierBreakdown: [{ label: 'Dedicated Pilot disabled', modifier: 2, weakened: true }], + const disabledPunchState = forceUnit.rules.getEquipmentToHit(punch); + expect((disabledPunchState).modifier).toBe(2); + expect(disabledPunchState).toEqual(jasmine.objectContaining({ + modifiers: [{ label: 'Dedicated Pilot disabled', modifier: 2, weakened: true }], })); }); @@ -1040,18 +1079,18 @@ describe('MekRules', () => { const superheavyPhysical = physical(superheavy); expect(superheavy.rules.PSRModifiers().modifiers.map(modifier => modifier.reason)).not.toContain('Superheavy'); - expect(superheavy.rules.computeEntryState(superheavyPhysical)).toEqual(jasmine.objectContaining({ - hitMod: 1, - hitModifierBreakdown: [{ label: 'Superheavy', modifier: 1 }], + const superheavyState = superheavy.rules.getEquipmentToHit(superheavyPhysical); + expect((superheavyState).modifier).toBe(1); + expect(superheavyState).toEqual(jasmine.objectContaining({ + modifiers: [{ label: 'Superheavy', modifier: 1 }], })); - const superheavyState = superheavy.rules.computeEntryState(superheavyPhysical); expect(superheavy.gameRules.resolveToHit({ subject: superheavyPhysical, - stateModifier: superheavyState.hitMod, - stateModifierBreakdown: superheavyState.hitModifierBreakdown, + stateModifier: (superheavyState).modifier, + stateModifierBreakdown: superheavyState.modifiers, }).weakened).toBeFalse(); - expect(superheavy.rules.computeEntryState(ranged)).toEqual(jasmine.objectContaining({ hitMod: 0 })); - expect(assault.rules.computeEntryState(physical(assault))).toEqual(jasmine.objectContaining({ hitMod: 0 })); + expect((superheavy.rules.getEquipmentToHit(ranged)).modifier).toBe(0); + expect((assault.rules.getEquipmentToHit(physical(assault))).modifier).toBe(0); }); it('does not apply gunnery modifiers to non-attack equipment', () => { @@ -1064,10 +1103,9 @@ describe('MekRules', () => { equipment: miscEquipment('Utility', 'Utility', []), }); - expect(forceUnit.rules.computeEntryState(utility)).toEqual(jasmine.objectContaining({ - hitMod: 0, - hitModifierBreakdown: [], - })); + const utilityState = forceUnit.rules.getEquipmentToHit(utility); + expect((utilityState).modifier).toBe(0); + expect(utilityState).toEqual(jasmine.objectContaining({ modifiers: [] })); }); it('does not apply the spotting attack modifier with an active command console', () => { @@ -1080,10 +1118,9 @@ describe('MekRules', () => { }); forceUnit.turnState().spotting.set(true); - expect(forceUnit.rules.computeEntryState(directFireWeaponEntry(forceUnit))).toEqual(jasmine.objectContaining({ - hitMod: 0, - hitModifierBreakdown: [], - })); + const noSpottingState = forceUnit.rules.getEquipmentToHit(directFireWeaponEntry(forceUnit)); + expect((noSpottingState).modifier).toBe(0); + expect(noSpottingState).toEqual(jasmine.objectContaining({ modifiers: [] })); }); it('applies the spotting attack modifier without a command console', () => { @@ -1093,10 +1130,9 @@ describe('MekRules', () => { }); forceUnit.turnState().spotting.set(true); - expect(forceUnit.rules.computeEntryState(directFireWeaponEntry(forceUnit))).toEqual(jasmine.objectContaining({ - hitMod: 1, - hitModifierBreakdown: [{ label: 'Spotting', modifier: 1 }], - })); + const spottingState = forceUnit.rules.getEquipmentToHit(directFireWeaponEntry(forceUnit)); + expect((spottingState).modifier).toBe(1); + expect(spottingState).toEqual(jasmine.objectContaining({ modifiers: [{ label: 'Spotting', modifier: 1 }] })); }); it('applies skidding and spotting to ranged and physical equipment modifiers', () => { @@ -1104,16 +1140,18 @@ describe('MekRules', () => { forceUnit.setCondition('skidding', true); forceUnit.turnState().spotting.set(true); - expect(forceUnit.rules.computeEntryState(directFireWeaponEntry(forceUnit))).toEqual(jasmine.objectContaining({ - hitMod: 2, - hitModifierBreakdown: [ + const rangedState = forceUnit.rules.getEquipmentToHit(directFireWeaponEntry(forceUnit)); + expect((rangedState).modifier).toBe(2); + expect(rangedState).toEqual(jasmine.objectContaining({ + modifiers: [ { label: 'Skidding', modifier: 1 }, { label: 'Spotting', modifier: 1 }, ], })); - expect(forceUnit.rules.computeEntryState(punchEntry(forceUnit))).toEqual(jasmine.objectContaining({ - hitMod: 2, - hitModifierBreakdown: [ + const physicalState = forceUnit.rules.getEquipmentToHit(punchEntry(forceUnit)); + expect((physicalState).modifier).toBe(2); + expect(physicalState).toEqual(jasmine.objectContaining({ + modifiers: [ { label: 'Skidding', modifier: 1 }, { label: 'Spotting', modifier: 1 }, ], @@ -1330,7 +1368,7 @@ describe('MekRules', () => { expect(storedEntry.committedDestroyed()).toBeFalse(); expect(forceUnit.getCritSlots()[0].destroyed).toBeTruthy(); - expect((forceUnit.rules as MekRules).computeEntryState(storedEntry)).toEqual(jasmine.objectContaining({ isDamaged: true })); + expect((forceUnit.rules as MekRules).getEquipmentStatus(storedEntry)).toBe('destroyed'); expect(forceUnit.getCondition('disconnected')).toBeTrue(); expect(forceUnit.getCondition('immobile')).toBeTrue(); @@ -1364,7 +1402,7 @@ describe('MekRules', () => { expect(forceUnit.getCritSlots()[0].destroyed).toBeFalsy(); expect(forceUnit.getCritSlots()[1].destroyed).toBeTruthy(); expect(storedEntry.committedDestroyed()).toBeFalse(); - expect(rules.computeEntryState(storedEntry)).toEqual(jasmine.objectContaining({ isDamaged: true })); + expect(rules.getEquipmentStatus(storedEntry)).toBe('destroyed'); }); it('requires two destroyed critical slots for Core2026 autocannons', () => { @@ -1384,12 +1422,12 @@ describe('MekRules', () => { forceUnit.applyHitToCritSlot(firstCrit); forceUnit.endPhase(); - expect(forceUnit.rules.computeEntryState(storedEntry).isDamaged) + expect(forceUnit.rules.getEquipmentStatus(storedEntry) === 'destroyed') .withContext(`${ammoType} after one destroyed critical slot`).toBeFalse(); forceUnit.applyHitToCritSlot(secondCrit); forceUnit.endPhase(); - expect(forceUnit.rules.computeEntryState(storedEntry).isDamaged) + expect(forceUnit.rules.getEquipmentStatus(storedEntry) === 'destroyed') .withContext(`${ammoType} after two destroyed critical slots`).toBeTrue(); } }); @@ -1404,7 +1442,7 @@ describe('MekRules', () => { forceUnit.applyHitToCritSlot(critSlot); forceUnit.endPhase(); - expect(forceUnit.rules.computeEntryState(forceUnit.getInventory()[0]).isDamaged).toBeTrue(); + expect(forceUnit.rules.getEquipmentStatus(forceUnit.getInventory()[0])).toBe('destroyed'); }); it('uses the one-slot threshold when a Core2026 autocannon signature does not match', () => { @@ -1424,8 +1462,8 @@ describe('MekRules', () => { forceUnit.applyHitToCritSlot(critSlot); forceUnit.endPhase(); - expect(forceUnit.rules.computeEntryState(forceUnit.getInventory()[0]).isDamaged) - .withContext(testCase.description).toBeTrue(); + expect(forceUnit.rules.getEquipmentStatus(forceUnit.getInventory()[0])) + .withContext(testCase.description).toBe('destroyed'); } }); @@ -2248,7 +2286,7 @@ describe('MekRules', () => { })); expect(rules.PSRModifiers().modifiers.some(modifier => modifier.reason === 'Leg Actuator(s) Destroyed')).toBeFalse(); expect(rules.PSRModifiers().modifiers).toContain(jasmine.objectContaining({ pilotCheck: 2, reason: 'Gyro damaged' })); - expect(rules.computeEntryState(armWeapon).hitMod).toBe(0); + expect((rules.getEquipmentToHit(armWeapon)).modifier).toBe(0); const twForceUnit = createForceUnitHarness({ internalLocations: ['LL', 'RL', 'LA', 'RA'], @@ -2270,7 +2308,7 @@ describe('MekRules', () => { pilotCheck: 1, loc: 'RL', reason: 'Leg Actuator(s) Destroyed', })); expect(twRules.PSRModifiers().modifiers).toContain(jasmine.objectContaining({ pilotCheck: 3, reason: 'Gyro damaged' })); - expect(twRules.computeEntryState(twArmWeapon).hitMod).toBe(1); + expect((twRules.getEquipmentToHit(twArmWeapon)).modifier).toBe(1); }); it('treats adding flooded and blown-off Mek locations as pending until phase commit', () => { @@ -2317,15 +2355,15 @@ describe('MekRules', () => { forceUnit.setLocationCondition('LL', 'flooded', true); - expect(rules.computeEntryState(entry)).toEqual(jasmine.objectContaining({ isDamaged: false, isDisabled: false })); + expect(rules.getEquipmentStatus(entry)).toBe('available'); forceUnit.endPhase(); - expect(rules.computeEntryState(entry)).toEqual(jasmine.objectContaining({ isDamaged: false, isDisabled: true })); + expect(rules.getEquipmentStatus(entry)).toBe('disabled'); forceUnit.setLocationCondition('LL', 'flooded', false); - expect(rules.computeEntryState(entry)).toEqual(jasmine.objectContaining({ isDamaged: false, isDisabled: false })); + expect(rules.getEquipmentStatus(entry)).toBe('available'); }); it('marks blown-off location inventory as damaged and disabled without destroying it', () => { @@ -2339,12 +2377,12 @@ describe('MekRules', () => { const storedEntry = forceUnit.getInventory().find(item => item.id === entry.id)!; forceUnit.setLocationCondition('LL', 'blown-off', true); forceUnit.endPhase(); - rules.computeAllEntryStates(); + rules.getEquipmentToHits(); expect(forceUnit.isInternalLocCommittedPhysicallyDestroyed('LL')).toBeTrue(); expect(forceUnit.getCritSlots().every(slot => !slot.destroying && !slot.destroyed)).toBeTrue(); expect(storedEntry.committedDestroyed()).toBeFalse(); - expect(rules.computeEntryState(storedEntry)).toEqual(jasmine.objectContaining({ isDamaged: true, isDisabled: true })); + expect(rules.getEquipmentStatus(storedEntry)).toBe('destroyed'); }); it('marks inventory in structurally destroyed locations as damaged and disabled', () => { @@ -2358,12 +2396,12 @@ describe('MekRules', () => { const storedEntry = forceUnit.getInventory().find(item => item.id === entry.id)!; forceUnit.addInternalHits('LL', forceUnit.getInternalPoints('LL')); forceUnit.endPhase(); - const entryStates = rules.computeAllEntryStates(); + const equipmentToHits = rules.getEquipmentToHits(); expect(forceUnit.isInternalLocCommittedStructurallyDestroyed('LL')).toBeTrue(); expect(storedEntry.committedDestroyed()).toBeFalse(); - expect(entryStates.get(storedEntry)).toEqual(jasmine.objectContaining({ isDamaged: true, isDisabled: true })); - expect(rules.computeEntryState(storedEntry)).toEqual(jasmine.objectContaining({ isDamaged: true, isDisabled: true })); + expect(equipmentToHits.has(storedEntry)).toBeTrue(); + expect(rules.getEquipmentStatus(storedEntry)).toBe('destroyed'); }); it('marks linked locations blown off by parent structural destruction as damaged and disabled', () => { @@ -2380,17 +2418,17 @@ describe('MekRules', () => { const storedLinkedEntry = forceUnit.getInventory().find(item => item.id === linkedEntry.id)!; forceUnit.addInternalHits('RT', forceUnit.getInternalPoints('RT')); forceUnit.endPhase(); - const entryStates = rules.computeAllEntryStates(); + const equipmentToHits = rules.getEquipmentToHits(); expect(forceUnit.isInternalLocCommittedStructurallyDestroyed('RT')).toBeTrue(); expect(forceUnit.isInternalLocCommittedStructurallyDestroyed('RA')).toBeFalse(); expect(forceUnit.isInternalLocCommittedPhysicallyDestroyed('RA')).toBeTrue(); expect(storedParentEntry.committedDestroyed()).toBeFalse(); expect(storedLinkedEntry.committedDestroyed()).toBeFalse(); - expect(entryStates.get(storedParentEntry)).toEqual(jasmine.objectContaining({ isDamaged: true, isDisabled: true })); - expect(entryStates.get(storedLinkedEntry)).toEqual(jasmine.objectContaining({ isDamaged: true, isDisabled: true })); - expect(rules.computeEntryState(storedParentEntry)).toEqual(jasmine.objectContaining({ isDamaged: true, isDisabled: true })); - expect(rules.computeEntryState(storedLinkedEntry)).toEqual(jasmine.objectContaining({ isDamaged: true, isDisabled: true })); + expect(equipmentToHits.has(storedParentEntry)).toBeTrue(); + expect(equipmentToHits.has(storedLinkedEntry)).toBeTrue(); + expect(rules.getEquipmentStatus(storedParentEntry)).toBe('destroyed'); + expect(rules.getEquipmentStatus(storedLinkedEntry)).toBe('destroyed'); }); it('disables linked-location inventory from flooded torsos without marking it damaged', () => { @@ -2403,7 +2441,7 @@ describe('MekRules', () => { expect(forceUnit.isInternalLocCommittedDestroyed('LA')).toBeTrue(); expect(forceUnit.isInternalLocCommittedPhysicallyDestroyed('LA')).toBeFalse(); - expect(rules.computeEntryState(entry)).toEqual(jasmine.objectContaining({ isDamaged: false, isDisabled: true })); + expect(rules.getEquipmentStatus(entry)).toBe('disabled'); }); it('counts flooded critical slots as functionally destroyed without committing crit destruction', () => { @@ -2461,3 +2499,5 @@ describe('MekRules', () => { expect(forceUnit.serialize().state.locations['LL'].conditions).toEqual(['blown-off']); }); }); + + diff --git a/src/app/models/rules/mek-rules.ts b/src/app/models/rules/mek-rules.ts index 47a385d3f..54e6d867c 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -7,7 +7,7 @@ import type { CBTForceUnit } from '../cbt-force-unit.model'; import type { CrewMember, SkillType } from '../crew-member.model'; import type { MountedEquipment } from '../mounted-equipment.model'; import type { CriticalSlot, RuleCheckOutcome } from '../force-serialization'; -import { CrewStateControlDefinition, CrewStateDefinition, crewStateDefinitions, sortPSRModifiers, UnitConditionControl, unitConditionControls, UnitTypeRulesBase, type ChargeDamage, type LocationConditionControl, type PSRCheck, type MountedEquipmentRuleState, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitRuleModifier } from './unit-type-rules'; +import { CrewStateControlDefinition, CrewStateDefinition, crewStateDefinitions, sortPSRModifiers, UnitConditionControl, unitConditionControls, UnitTypeRulesBase, type ChargeDamage, type LocationConditionControl, type PSRCheck, type MountedEquipmentStatus, type MountedEquipmentToHit, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitRuleModifier } from './unit-type-rules'; import type { TurnState } from '../turn-state.model'; import { type HeatScaleEntry, HeatManagement, getHeatEffects } from './heat-management'; import type { MotiveModes } from '../motiveModes.model'; @@ -1825,20 +1825,18 @@ export class MekRules extends UnitTypeRulesBase { // ── Per-Entry Inventory State ───────────────────────────────────────────── - /** - * Compute game state for ALL inventory entries in a single pass. - */ - private readonly entryStates = computed>(() => { + /** Compute to-hit state for all inventory entries in one reactive pass. */ + private readonly equipmentToHits = computed>(() => { const entries = this.unit.getInventory(); - const result = new Map(); + const result = new Map(); for (const entry of entries) { - result.set(entry, this.computeEntryState(entry)); + result.set(entry, this.getEquipmentToHit(entry)); } return result; }); - override computeAllEntryStates(): Map { - return this.entryStates(); + override getEquipmentToHits(): Map { + return this.equipmentToHits(); } private isEntryDestroyedByCriticalDamage(entry: MountedEquipment): boolean { @@ -1852,28 +1850,59 @@ export class MekRules extends UnitTypeRulesBase { return isAutocannon ? 2 : 1; } - /** - * Compute per-entry game state (damaged/disabled/hitMod) for an inventory entry. - */ - override computeEntryState(entry: MountedEquipment): MountedEquipmentRuleState { + /** Resolve operational status without modifier-producing equipment interactions. */ + override getEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus { const physicallyDestroyed = this.entryInPhysicallyDestroyedLocation(entry); const functionallyDestroyed = this.entryInFunctionallyDestroyedLocation(entry); - const isDamaged = entry.committedDestroyed() || physicallyDestroyed || this.isEntryDestroyedByCriticalDamage(entry); - let isDisabled = functionallyDestroyed || this.isEntryStateDisabled(entry); - const hitModifierBreakdown: ToHitModifierBreakdownEntry[] = []; + if (entry.committedDestroyed() || physicallyDestroyed || this.isEntryDestroyedByCriticalDamage(entry)) { + return 'destroyed'; + } + let disabled = functionallyDestroyed || this.isEntryStateDisabled(entry); + const physical = this.physicalCombat(); + const fire = this.fireControl(); + if (!physical || !fire) return disabled ? 'disabled' : 'available'; + + if (entry.isIntrinsicPhysicalAttack()) { + switch (entry.name.toLowerCase()) { + case 'punch': + const loc = Array.from(entry.locations!)[0] as ArmLocation; + if (loc in physical.canPunch && !physical.canPunch[loc]) disabled = true; + break; + case 'club': + if (!physical.canClub) disabled = true; + break; + case 'push': + if (!physical.canPush) disabled = true; + break; + case 'kick [talons]': + case 'kick': + if (!physical.canKick) disabled = true; + break; + } + } else if (entry.isPhysicalWeapon()) { + entry.locations?.forEach(loc => { + if ((loc in physical.canPhysWeapon) && !physical.canPhysWeapon[loc as ArmLocation]) disabled = true; + }); + } else if (!fire.canFire) { + disabled = true; + } + return disabled ? 'disabled' : 'available'; + } + + protected override getEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[] { + const hitModifierBreakdown: ToHitModifierBreakdownEntry[] = []; const physical = this.physicalCombat(); const fire = this.fireControl(); const systemsStatus = this.systemsStatus(); if (!physical || !fire) { - return this.composeEntryState(entry, { isDamaged, isDisabled }, hitModifierBreakdown); + return [...hitModifierBreakdown, ...this.getUnitEquipmentToHitModifiers(entry)]; } if (entry.isIntrinsicPhysicalAttack()) { switch (entry.name.toLowerCase()) { case 'punch': { const loc = Array.from(entry.locations!)[0] as ArmLocation; - if (loc in physical.canPunch && !physical.canPunch[loc]) isDisabled = true; if (loc in physical.punchMod) { this.addArmActuatorBreakdown(hitModifierBreakdown, systemsStatus.locationModifiers[loc], loc, { hand: 1, @@ -1885,19 +1914,14 @@ export class MekRules extends UnitTypeRulesBase { this.addArmAESBreakdown(hitModifierBreakdown, systemsStatus.locationModifiers[loc], loc, aesModifier); break; } - case 'club': { - if (!physical.canClub) isDisabled = true; + case 'club': this.addTwoArmPhysicalBreakdown(hitModifierBreakdown, systemsStatus.locationModifiers, 'club'); break; - } - case 'push': { - if (!physical.canPush) isDisabled = true; + case 'push': this.addTwoArmPhysicalBreakdown(hitModifierBreakdown, systemsStatus.locationModifiers, 'push'); break; - } case 'kick [talons]': - case 'kick': { - if (!physical.canKick) isDisabled = true; + case 'kick': if (systemsStatus.destroyedLegActuatorsCount > 0) { hitModifierBreakdown.push({ label: this.countedDestroyedLabel('Leg Actuator', systemsStatus.destroyedLegActuatorsCount), @@ -1918,11 +1942,9 @@ export class MekRules extends UnitTypeRulesBase { hitModifierBreakdown.push({ label: 'Leg AES Destroyed', modifier: 0, weakened: true }); } break; - } } } else if (entry.isPhysicalWeapon()) { entry.locations?.forEach(loc => { - if ((loc in physical.canPhysWeapon) && !physical.canPhysWeapon[loc as ArmLocation]) isDisabled = true; if (loc in physical.physWeaponMod) { const armLoc = loc as ArmLocation; const armStatus = systemsStatus.locationModifiers[armLoc]; @@ -1938,7 +1960,6 @@ export class MekRules extends UnitTypeRulesBase { if (entry.locations?.size === 1) { const singleLoc = Array.from(entry.locations)[0]; if (singleLoc in fire.singleArmMod) { - const armModifier = fire.singleArmMod[singleLoc as ArmLocation]; const armStatus = systemsStatus.locationModifiers[singleLoc]; if (armStatus?.hasAES) { hitModifierBreakdown.push(armStatus.hasFunctionalAES @@ -1947,7 +1968,6 @@ export class MekRules extends UnitTypeRulesBase { } } } - if (!fire.canFire) isDisabled = true; entry.locations?.forEach(loc => { if (!(loc in fire.fireMod)) return; const armStatus = systemsStatus.locationModifiers[loc]; @@ -1973,7 +1993,7 @@ export class MekRules extends UnitTypeRulesBase { } } } - return this.composeEntryState(entry, { isDamaged, isDisabled }, hitModifierBreakdown); + return [...hitModifierBreakdown, ...this.getUnitEquipmentToHitModifiers(entry)]; } private addArmActuatorBreakdown( diff --git a/src/app/models/rules/unit-type-rules.ts b/src/app/models/rules/unit-type-rules.ts index e41d842e5..49d80aae1 100644 --- a/src/app/models/rules/unit-type-rules.ts +++ b/src/app/models/rules/unit-type-rules.ts @@ -66,11 +66,11 @@ export interface UnitHeatSource { replacedByFiringEntryId?: string; } -export interface MountedEquipmentRuleState { - isDamaged: boolean; - isDisabled: boolean; - hitMod: number; - hitModifierBreakdown?: ToHitModifierBreakdownEntry[]; +export type MountedEquipmentStatus = 'available' | 'disabled' | 'destroyed'; + +export interface MountedEquipmentToHit { + readonly modifier: number; + readonly modifiers: readonly ToHitModifierBreakdownEntry[]; } export interface ChargeDamage { @@ -290,11 +290,14 @@ export interface UnitTypeRules { /** Rule-derived condition keys exposed through ForceUnit.getCondition/getConditions. */ computedConditions(): readonly string[]; - /** Compute rule-derived availability and hit modifiers for all inventory entries. */ - computeAllEntryStates(): Map; + /** Resolve operational status without invoking equipment interaction handlers. */ + getEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus; + + /** Resolve to-hit modifiers for all inventory entries. */ + getEquipmentToHits(): Map; - /** Compute rule-derived availability and hit modifiers for a single inventory entry. */ - computeEntryState(entry: MountedEquipment): MountedEquipmentRuleState; + /** Resolve rule-derived to-hit modifiers for one inventory entry. */ + getEquipmentToHit(entry: MountedEquipment): MountedEquipmentToHit; /** Required control-roll checks for the current phase. */ getPSRChecks(turnState: TurnState): PSRCheck[]; @@ -477,37 +480,43 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { return ['abandoned', 'immobile', 'crippled', 'disconnected', 'spotting']; } - computeAllEntryStates(): Map { - const result = new Map(); + getEquipmentToHits(): Map { + const result = new Map(); for (const entry of this.unit.getInventory()) { - result.set(entry, this.computeEntryState(entry)); + result.set(entry, this.getEquipmentToHit(entry)); } return result; } - computeEntryState(entry: MountedEquipment): MountedEquipmentRuleState { - return this.composeEntryState(entry, { - isDamaged: entry.committedDestroyed() || this.entryCriticalSlots(entry).some(slot => !!slot.destroyed), - isDisabled: this.isEntryStateDisabled(entry), - }, this.getMountedTargetingComputerModifiers(entry)); + getEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus { + if (entry.committedDestroyed() || this.entryCriticalSlots(entry).some(slot => !!slot.destroyed)) { + return 'destroyed'; + } + return this.isEntryStateDisabled(entry) ? 'disabled' : 'available'; } - protected composeEntryState( - entry: MountedEquipment, - state: Pick, - localModifiers: readonly ToHitModifierBreakdownEntry[] = [], - ): MountedEquipmentRuleState { + getEquipmentToHit(entry: MountedEquipment): MountedEquipmentToHit { + const modifiers = this.getEquipmentToHitModifiers(entry); + return { + modifier: modifiers.reduce((total, modifier) => total + modifier.modifier, 0), + modifiers, + }; + } + + protected getEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[] { + return [ + ...this.getMountedTargetingComputerModifiers(entry), + ...this.getUnitEquipmentToHitModifiers(entry), + ]; + } + + protected getUnitEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[] { const unitModifiers = entry.isPhysicalWeapon() ? this.physicalHitModifiers() : entry.equipment instanceof WeaponEquipment ? this.rangedHitModifiers() : []; - const modifiers = [...localModifiers, ...unitModifiers]; - return { - ...state, - hitMod: modifiers.reduce((total, modifier) => total + modifier.modifier, 0), - hitModifierBreakdown: modifiers, - }; + return unitModifiers; } protected getMountedTargetingComputerModifiers(entry: MountedEquipment): ToHitModifierBreakdownEntry[] { diff --git a/src/app/models/rules/vehicle-rules.spec.ts b/src/app/models/rules/vehicle-rules.spec.ts index 591cf6a47..1b190099f 100644 --- a/src/app/models/rules/vehicle-rules.spec.ts +++ b/src/app/models/rules/vehicle-rules.spec.ts @@ -156,7 +156,7 @@ describe('VehicleRules', () => { const targetingComputer = entry({ equipment: equipment('TargetingComputer', ['F_TARGETING_COMPUTER']) }); const activeRules = createRulesHarness({ inventory: [directFire, targetingComputer] }); - expect(activeRules.computeEntryState(directFire)).toEqual(jasmine.objectContaining({ hitMod: -1 })); + expect(activeRules.getEquipmentToHit(directFire).modifier).toBe(-1); const destroyedDirectFire = new MountedWeapon({ owner: undefined as unknown as CBTForceUnit, @@ -170,10 +170,9 @@ describe('VehicleRules', () => { }); const destroyedRules = createRulesHarness({ inventory: [destroyedDirectFire, destroyedTargetingComputer] }); - expect(destroyedRules.computeEntryState(destroyedDirectFire)).toEqual(jasmine.objectContaining({ - hitMod: 0, - hitModifierBreakdown: [{ label: 'DestroyedTargetingComputer Destroyed', modifier: 0, weakened: true }], - })); + expect(destroyedRules.getEquipmentToHit(destroyedDirectFire).modifier).toBe(0); + expect(destroyedRules.getEquipmentToHit(destroyedDirectFire).modifiers) + .toEqual([{ label: 'DestroyedTargetingComputer Destroyed', modifier: 0, weakened: true }]); }); it('does not apply a targeting computer when selected ammo creates a cluster flak attack', () => { @@ -203,7 +202,7 @@ describe('VehicleRules', () => { selectedAmmo: flechetteAmmo }); - expect(rules.computeEntryState(mountedAutocannon)).toEqual(jasmine.objectContaining({ hitMod: 0 })); + expect(rules.getEquipmentToHit(mountedAutocannon).modifier).toBe(0); }); it('excludes cluster and flak weapons from targeting computers except non-flak HAGs', () => { @@ -240,9 +239,9 @@ describe('VehicleRules', () => { }); const rules = createRulesHarness({ inventory: [clusterWeapon, flakWeapon, hag, targetingComputer] }); - expect(rules.computeEntryState(clusterWeapon)).toEqual(jasmine.objectContaining({ hitMod: 0 })); - expect(rules.computeEntryState(flakWeapon)).toEqual(jasmine.objectContaining({ hitMod: 0 })); - expect(rules.computeEntryState(hag)).toEqual(jasmine.objectContaining({ hitMod: -1 })); + expect(rules.getEquipmentToHit(clusterWeapon).modifier).toBe(0); + expect(rules.getEquipmentToHit(flakWeapon).modifier).toBe(0); + expect(rules.getEquipmentToHit(hag).modifier).toBe(-1); }); it('does not allow pulse weapons to make aimed shots against mobile targets', () => { @@ -547,13 +546,12 @@ describe('VehicleRules', () => { { label: 'Sensor hits', modifier: 3, weakened: true }, ]; expect(rules.getBaseGunnerySkill()).toBe(4); - const weaponState = rules.computeEntryState(weaponEntry); - expect(weaponState.hitMod).toBe(6); - expect(weaponState.hitModifierBreakdown).toEqual(expectedRangedModifiers); - expect(rules.getBaseGunnerySkill() + weaponState.hitMod).toBe(10); - expect(rules.computeEntryState(physicalEntry)).toEqual(jasmine.objectContaining({ - hitMod: 1, - hitModifierBreakdown: [ + const weaponState = rules.getEquipmentToHit(weaponEntry); + expect(weaponState.modifier).toBe(6); + expect(weaponState.modifiers).toEqual(expectedRangedModifiers); + expect(rules.getBaseGunnerySkill() + weaponState.modifier).toBe(10); + expect(rules.getEquipmentToHit(physicalEntry)).toEqual(jasmine.objectContaining({ + modifiers: [ { label: 'Commander hit', modifier: 1, weakened: true }, ], })); @@ -579,14 +577,10 @@ describe('VehicleRules', () => { expect(rules.hasComputedCondition('disconnected')).toBeTrue(); expect(rules.hasComputedCondition('immobile')).toBeTrue(); expect(rules.movementState()).toEqual(jasmine.objectContaining({ walk: 0, run: 0, moveImpaired: true })); - expect(rules.computeEntryState(weaponEntry)).toEqual(jasmine.objectContaining({ - hitMod: 0, - hitModifierBreakdown: [], - })); - expect(rules.computeEntryState(physicalEntry)).toEqual(jasmine.objectContaining({ - hitMod: 0, - hitModifierBreakdown: [], - })); + expect(rules.getEquipmentToHit(weaponEntry).modifier).toBe(0); + expect(rules.getEquipmentToHit(weaponEntry)).toEqual(jasmine.objectContaining({ modifiers: [] })); + expect(rules.getEquipmentToHit(physicalEntry).modifier).toBe(0); + expect(rules.getEquipmentToHit(physicalEntry)).toEqual(jasmine.objectContaining({ modifiers: [] })); expect(rules.PSRModifiers().modifier).toBe(0); }); @@ -614,8 +608,8 @@ describe('VehicleRules', () => { inventory: [energyEntry, ballisticEntry], }); - expect(rules.computeEntryState(energyEntry).isDisabled).toBeTrue(); - expect(rules.computeEntryState(ballisticEntry).isDisabled).toBeFalse(); + expect(rules.getEquipmentStatus(energyEntry)).toBe('disabled'); + expect(rules.getEquipmentStatus(ballisticEntry)).toBe('available'); }); it('disables non-physical weapons at Sensor hits level four', () => { @@ -626,8 +620,8 @@ describe('VehicleRules', () => { inventory: [weaponEntry, chargeEntry], }); - expect(rules.computeEntryState(weaponEntry).isDisabled).toBeTrue(); - expect(rules.computeEntryState(chargeEntry).isDisabled).toBeFalse(); + expect(rules.getEquipmentStatus(weaponEntry)).toBe('disabled'); + expect(rules.getEquipmentStatus(chargeEntry)).toBe('available'); }); it('calculates charge damage for core2026 vehicles and preserves TW sheet damage', () => { @@ -658,9 +652,9 @@ describe('VehicleRules', () => { moveMode: 'run', }); - expect(rules.computeEntryState(frontWeapon).hitMod).toBe(2); - expect(rules.computeEntryState(rearWeapon).hitMod).toBe(0); - expect(rules.computeEntryState(frontRightWeapon).hitMod).toBe(2); + expect(rules.getEquipmentToHit(frontWeapon).modifier).toBe(2); + expect(rules.getEquipmentToHit(rearWeapon).modifier).toBe(0); + expect(rules.getEquipmentToHit(frontRightWeapon).modifier).toBe(2); }); it('reports stabilizer-affected weapons before movement mode is selected', () => { @@ -672,11 +666,11 @@ describe('VehicleRules', () => { moveMode: null, }); - expect(rules.computeEntryState(frontRightWeapon).hitMod).toBe(0); - expect(rules.computeEntryState(frontRightWeapon).hitModifierBreakdown).toContain(jasmine.objectContaining({ + expect(rules.getEquipmentToHit(frontRightWeapon).modifier).toBe(0); + expect(rules.getEquipmentToHit(frontRightWeapon).modifiers).toContain(jasmine.objectContaining({ label: 'Stabilizer Hit', modifier: 0, weakened: true, })); - expect(rules.computeEntryState(rearWeapon).hitModifierBreakdown).not.toContain(jasmine.objectContaining({ + expect(rules.getEquipmentToHit(rearWeapon).modifiers).not.toContain(jasmine.objectContaining({ label: 'Stabilizer Hit', })); }); diff --git a/src/app/models/rules/vehicle-rules.ts b/src/app/models/rules/vehicle-rules.ts index e1aea1313..054bc90db 100644 --- a/src/app/models/rules/vehicle-rules.ts +++ b/src/app/models/rules/vehicle-rules.ts @@ -4,7 +4,7 @@ import { computed } from '@angular/core'; import type { CBTForceUnit } from '../cbt-force-unit.model'; -import type { CrewStateControlDefinition, CrewStateDefinition, UnitConditionControl, MountedEquipmentRuleState, UnitRuleModifier } from './unit-type-rules'; +import type { CrewStateControlDefinition, CrewStateDefinition, UnitConditionControl, MountedEquipmentStatus, UnitRuleModifier } from './unit-type-rules'; import type { ToHitModifierBreakdownEntry } from './game-rules'; import { crewStateDefinitions, sortPSRModifiers, unitConditionControls, UnitTypeRulesBase } from './unit-type-rules'; import type { PSRCheck, TurnState } from '../turn-state.model'; @@ -263,35 +263,36 @@ export class VehicleRules extends UnitTypeRulesBase { override readonly PSRTargetRoll = computed(() => this.unit.pilotingSkill() + this.PSRModifiers().modifier); - override computeAllEntryStates(): Map { - const result = new Map(); - for (const entry of this.unit.getInventory()) { - result.set(entry, this.computeEntryState(entry)); + override getEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus { + const status = this.systemsStatus(); + if (this.entryCriticalSlots(entry).some(slot => slot.destroyed) || entry.committedDestroyed()) { + return 'destroyed'; } - return result; + let disabled = this.isEntryStateDisabled(entry); + + if (!this.isPhysicalEntry(entry)) { + if (status.engineHit && entry.equipment?.flags.has('F_ENERGY')) { + disabled = true; + } + if (status.sensorHits >= 4 && entry.equipment instanceof WeaponEquipment) { + disabled = true; + } + } + return disabled ? 'disabled' : 'available'; } - override computeEntryState(entry: MountedEquipment): MountedEquipmentRuleState { + protected override getEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[] { const status = this.systemsStatus(); - const isDamaged = this.entryCriticalSlots(entry).some(slot => slot.destroyed) || entry.committedDestroyed(); - let isDisabled = this.isEntryStateDisabled(entry); const hitModifierBreakdown: ToHitModifierBreakdownEntry[] = []; - const isPhysical = this.isPhysicalEntry(entry); - if (!isPhysical) { + if (!this.isPhysicalEntry(entry)) { hitModifierBreakdown.push(...this.getMountedTargetingComputerModifiers(entry)); - if (status.engineHit && entry.equipment?.flags.has('F_ENERGY')) { - isDisabled = true; - } - if (status.sensorHits >= 4 && entry.equipment instanceof WeaponEquipment) { - isDisabled = true; - } const stabilizerModifier = this.stabilizerHitModifier(entry, status); if (this.stabilizerHitApplies(entry, status)) { hitModifierBreakdown.push({ label: 'Stabilizer Hit', modifier: stabilizerModifier, weakened: true }); } } - return this.composeEntryState(entry, { isDamaged, isDisabled }, hitModifierBreakdown); + return [...hitModifierBreakdown, ...this.getUnitEquipmentToHitModifiers(entry)]; } private applyMotiveDamage(base: number, motiveHits: MotiveHitTimestamp[]): number { diff --git a/src/app/services/equipment-interaction-registry.service.spec.ts b/src/app/services/equipment-interaction-registry.service.spec.ts index 550f3e7ab..d4feae10c 100644 --- a/src/app/services/equipment-interaction-registry.service.spec.ts +++ b/src/app/services/equipment-interaction-registry.service.spec.ts @@ -10,7 +10,7 @@ import { type Equipment, WeaponEquipment } from '../models/equipment.model'; import { EquipmentRegistry } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; -import { createEmptyUnit } from '../testing/unit-test-helpers'; +import { createEmptyUnit, createTestEquipmentRules } from '../testing/unit-test-helpers'; import { EquipmentInteractionHandler, EquipmentInteractionRegistryService, type HandlerContext } from './equipment-interaction-registry.service'; import type { Force } from '../models/force.model'; @@ -25,7 +25,7 @@ function owner(gameRules?: CBTGameRules): never { gameRules, getUnit: () => createEmptyUnit(), isEquipmentActionUnavailable: () => false, - rules: { computeEntryState: () => ({ isDamaged: false, isDisabled: false, hitMod: 0 }) }, + rules: createTestEquipmentRules(), } as never; } @@ -248,7 +248,9 @@ describe('EquipmentInteractionRegistryService', () => { }); apollo.owner = { ...apollo.owner, - rules: { computeEntryState: (candidate: MountedEquipment) => ({ isDamaged: candidate === apollo, isDisabled: false, hitMod: 0 }) } + rules: createTestEquipmentRules({ + getEquipmentStatus: (candidate: MountedEquipment) => candidate === apollo ? 'destroyed' : 'available', + }) } as never; const mrm = new MountedEquipment({ owner: owner(TW_GAME_RULES), diff --git a/src/app/services/unit-svg-mek.service.ts b/src/app/services/unit-svg-mek.service.ts index 36563ed1e..10b3baafb 100644 --- a/src/app/services/unit-svg-mek.service.ts +++ b/src/app/services/unit-svg-mek.service.ts @@ -10,14 +10,14 @@ import { AmmoEquipment } from "../models/equipment.model"; import { MekRules } from "../models/rules/mek-rules"; import type { InventoryControlRuntimeRangeKey } from "../models/inventory-control-runtime-state.model"; import { getCriticalSlotAmmoProfileKey } from "../utils/ammo-interaction.util"; -import type { MountedEquipmentRuleState } from "../models/rules/unit-type-rules"; +import type { MountedEquipmentToHit } from "../models/rules/unit-type-rules"; import { INVENTORY_CONTROL_PHYSICAL_BASE_DAMAGE_TEXT_ATTRIBUTE, readInventoryControlDisplayData } from "../utils/inventory-control.util"; export class UnitSvgMekService extends UnitSvgService { // Mek-specific SVG handling logic goes here private get mekRules(): MekRules { return this.unit.rules as MekRules; } - private currentEntryStates: Map | null = null; + private currentEquipmentToHits: Map | null = null; protected override updateAllDisplays() { if (!this.unit.svg()) return; @@ -179,14 +179,14 @@ export class UnitSvgMekService extends UnitSvgService { } // Inventory entries — state from rules, rendering here - const entryStates = this.mekRules.computeAllEntryStates(); - this.currentEntryStates = entryStates; + const equipmentToHits = this.mekRules.getEquipmentToHits(); + this.currentEquipmentToHits = equipmentToHits; try { this.unit.getInventory().forEach(entry => { if (!entry.el || !entry.locations) return; - const state = entryStates.get(entry); - if (!state) return; + const toHit = equipmentToHits.get(entry); + if (!toHit) return; // Physical / melee damage display (reads base values from DOM, computes via rules) if (entry.isIntrinsicPhysicalAttack()) { @@ -211,25 +211,26 @@ export class UnitSvgMekService extends UnitSvgService { const actionUnavailable = entry.isActionUnavailable(); entry.el.classList.toggle('disabledInventory', actionUnavailable); - entry.el.classList.toggle('damagedInventory', state.isDamaged); - if (state.isDamaged || actionUnavailable) entry.el.classList.remove('selected'); + const destroyed = this.mekRules.getEquipmentStatus(entry) === 'destroyed'; + entry.el.classList.toggle('damagedInventory', destroyed); + if (destroyed || actionUnavailable) entry.el.classList.remove('selected'); // Hit modifier badge this.renderHitModEntry(entry, this.resolveInventoryControlToHit(entry)); }); this.renderInventoryControlSelection(); } finally { - this.currentEntryStates = null; + this.currentEquipmentToHits = null; } } protected override resolveInventoryControlToHit(entry: MountedEquipment, range?: InventoryControlRuntimeRangeKey | null) { - const state = this.currentEntryStates?.get(entry) ?? this.mekRules.computeEntryState(entry); + const toHit = this.currentEquipmentToHits?.get(entry) ?? this.mekRules.getEquipmentToHit(entry); const selectedAmmo = this.inventoryTargetSelectedAmmo(entry); return this.unit.gameRules.resolveToHit({ subject: entry, - stateModifier: state.hitMod, - stateModifierBreakdown: state.hitModifierBreakdown, + stateModifier: toHit.modifier, + stateModifierBreakdown: toHit.modifiers, range, adjustments: this.unit.getInventoryControlRules().resolveToHitAdjustments?.(entry, selectedAmmo) }); diff --git a/src/app/services/unit-svg-vehicle.service.ts b/src/app/services/unit-svg-vehicle.service.ts index 41a9988f4..3057d9afc 100644 --- a/src/app/services/unit-svg-vehicle.service.ts +++ b/src/app/services/unit-svg-vehicle.service.ts @@ -5,7 +5,7 @@ import type { MountedEquipment } from "../models/mounted-equipment.model"; import type { CriticalSlot } from "../models/force-serialization"; import { VehicleRules } from "../models/rules/vehicle-rules"; -import type { MountedEquipmentRuleState } from "../models/rules/unit-type-rules"; +import type { MountedEquipmentToHit } from "../models/rules/unit-type-rules"; import type { InventoryControlRuntimeRangeKey } from "../models/inventory-control-runtime-state.model"; import { committedCriticalHitCount, isRepeatableMotiveHitId, MOTIVE_HIT_PIP_COUNT } from "../models/rules/vehicle-motive-hit.util"; import { UnitSvgService } from "./unit-svg.service"; @@ -15,7 +15,7 @@ const VTOL_ROTOR_CRIT_ID = 'rotor'; export class UnitSvgVehicleService extends UnitSvgService { private get vehicleRules(): VehicleRules { return this.unit.rules as VehicleRules; } - private currentEntryStates: Map | null = null; + private currentEquipmentToHits: Map | null = null; protected override updateAllDisplays() { if (!this.unit.svg()) return; @@ -127,8 +127,8 @@ export class UnitSvgVehicleService extends UnitSvgService { } } - const entryStates = this.vehicleRules.computeAllEntryStates(); - this.currentEntryStates = entryStates; + const equipmentToHits = this.vehicleRules.getEquipmentToHits(); + this.currentEquipmentToHits = equipmentToHits; try { this.unit.getInventory().forEach(entry => { if (!entry.el) return; @@ -137,29 +137,30 @@ export class UnitSvgVehicleService extends UnitSvgService { this.renderChargeDamage(entry, this.vehicleRules.chargeDamage()); } } - const state = entryStates.get(entry); - if (!state) return; + const toHit = equipmentToHits.get(entry); + if (!toHit) return; const actionUnavailable = entry.isActionUnavailable(); entry.el.classList.toggle('disabledInventory', actionUnavailable); - entry.el.classList.toggle('damagedInventory', state.isDamaged); - if (state.isDamaged || actionUnavailable) entry.el.classList.remove('selected'); + const destroyed = this.vehicleRules.getEquipmentStatus(entry) === 'destroyed'; + entry.el.classList.toggle('damagedInventory', destroyed); + if (destroyed || actionUnavailable) entry.el.classList.remove('selected'); this.renderHitModEntry(entry, this.resolveInventoryControlToHit(entry)); }); this.renderInventoryControlSelection(); } finally { - this.currentEntryStates = null; + this.currentEquipmentToHits = null; } } protected override resolveInventoryControlToHit(entry: MountedEquipment, range?: InventoryControlRuntimeRangeKey | null) { - const state = this.currentEntryStates?.get(entry) ?? this.vehicleRules.computeEntryState(entry); + const toHit = this.currentEquipmentToHits?.get(entry) ?? this.vehicleRules.getEquipmentToHit(entry); const selectedAmmo = this.inventoryTargetSelectedAmmo(entry); return this.unit.gameRules.resolveToHit({ subject: entry, - stateModifier: state.hitMod, - stateModifierBreakdown: state.hitModifierBreakdown, + stateModifier: toHit.modifier, + stateModifierBreakdown: toHit.modifiers, range, adjustments: this.unit.getInventoryControlRules().resolveToHitAdjustments?.(entry, selectedAmmo) }); diff --git a/src/app/services/unit-svg.service.ts b/src/app/services/unit-svg.service.ts index ef4dc65d4..636bf30a1 100644 --- a/src/app/services/unit-svg.service.ts +++ b/src/app/services/unit-svg.service.ts @@ -1252,12 +1252,12 @@ export class UnitSvgService { } protected resolveInventoryControlToHit(entry: MountedEquipment, range?: InventoryControlRuntimeRangeKey | null): ToHitResolution { - const state = this.unit.rules.computeEntryState(entry); + const toHit = this.unit.rules.getEquipmentToHit(entry); const selectedAmmo = this.inventoryTargetSelectedAmmo(entry); return this.unit.gameRules.resolveToHit({ subject: entry, - stateModifier: state.hitMod, - stateModifierBreakdown: state.hitModifierBreakdown, + stateModifier: toHit.modifier, + stateModifierBreakdown: toHit.modifiers, range, adjustments: this.unit.getInventoryControlRules().resolveToHitAdjustments?.(entry, selectedAmmo) }); @@ -1614,7 +1614,7 @@ export class UnitSvgService { if (!svg) return; this.unit.getInventory().forEach(entry => { if (!entry.el) return; - const state = this.unit.rules.computeEntryState(entry); + const status = this.unit.rules.getEquipmentStatus(entry); const actionUnavailable = entry.isActionUnavailable(); if (entry.isIntrinsicPhysicalAttack()) { if (entry.name === 'charge') { @@ -1623,10 +1623,11 @@ export class UnitSvgService { } // Inventory state entry.el.classList.toggle('disabledInventory', actionUnavailable); - entry.el.classList.toggle('damagedInventory', state.isDamaged); - if (state.isDamaged || actionUnavailable) entry.el.classList.remove('selected'); + const destroyed = status === 'destroyed'; + entry.el.classList.toggle('damagedInventory', destroyed); + if (destroyed || actionUnavailable) entry.el.classList.remove('selected'); // Hit modifier badge - if (state.isDamaged) { + if (destroyed) { this.renderHitModEntry(entry, { profile: [], value: null, changed: false, weakened: false, modifierBreakdown: [] }); } else { this.renderHitModEntry( diff --git a/src/app/testing/unit-test-helpers.spec.ts b/src/app/testing/unit-test-helpers.spec.ts index 6d288f0aa..93d288265 100644 --- a/src/app/testing/unit-test-helpers.spec.ts +++ b/src/app/testing/unit-test-helpers.spec.ts @@ -63,7 +63,7 @@ describe('CBTForceUnitTestHarness', () => { }); expect(harness.unit.gameRules).toBe(CORE_2026_GAME_RULES); - expect(harness.unit.rules.computeEntryState(mounted).isDisabled).toBeTrue(); + expect(harness.unit.rules.getEquipmentStatus(mounted)).toBe('disabled'); }); it('reports no active conditions by default', () => { diff --git a/src/app/testing/unit-test-helpers.ts b/src/app/testing/unit-test-helpers.ts index 32cd8de4d..4390cec09 100644 --- a/src/app/testing/unit-test-helpers.ts +++ b/src/app/testing/unit-test-helpers.ts @@ -13,7 +13,7 @@ import { type MountedEquipmentInit, MountedEquipment } from '../models/mounted- import { type CriticalSlot, type HeatProfile } from '../models/force-serialization'; import { getMotiveModeLabel, type MotiveModes } from '../models/motiveModes.model'; import { ATTACK_MOVEMENT_MODIFIER_BREAKDOWN_PRIORITY, CORE_2026_GAME_RULES, type CBTGameRules, type C3DegradationSource, type ToHitAdjustment, type ToHitModifierBreakdownEntry } from '../models/rules/game-rules'; -import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE, type UnitModifierBreakdownEntry } from '../models/rules/unit-type-rules'; +import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE, type MountedEquipmentStatus, type MountedEquipmentToHit, type UnitModifierBreakdownEntry } from '../models/rules/unit-type-rules'; import { resolveSelectedInventoryWeaponHeat } from '../utils/inventory-control-heat.util'; import type { InventoryControlDisplayData, InventoryControlRules } from '../utils/inventory-control.util'; @@ -146,10 +146,38 @@ export function createEmptyUnit(overrides: TestUnitOverrides = {}): Unit { } export interface CBTForceUnitTestEntryState { - isDamaged: boolean; - isDisabled: boolean; - hitMod: number; - hitModifierBreakdown?: readonly ToHitModifierBreakdownEntry[]; + status: MountedEquipmentStatus; + toHit: MountedEquipmentToHit; +} + +export function createTestEquipmentState( + status: MountedEquipmentStatus = 'available', + modifiers: readonly ToHitModifierBreakdownEntry[] = [], +): CBTForceUnitTestEntryState { + return { + status, + toHit: { + modifier: modifiers.reduce((total, modifier) => total + modifier.modifier, 0), + modifiers, + }, + }; +} + +export interface TestEquipmentRulesOptions { + getEquipmentStatus?: (entry: MountedEquipment) => MountedEquipmentStatus; + getEquipmentToHit?: (entry: MountedEquipment) => MountedEquipmentToHit; +} + +export function createTestEquipmentRules(options: TestEquipmentRulesOptions = {}) { + const getEquipmentStatus = options.getEquipmentStatus + ?? ((entry: MountedEquipment): MountedEquipmentStatus => entry.committedDestroyed() ? 'destroyed' : 'available'); + const getEquipmentToHit = options.getEquipmentToHit + ?? (() => ({ modifier: 0, modifiers: [] })); + return { + getEquipmentStatus, + getEquipmentToHit, + getEquipmentToHits: () => new Map(), + }; } export interface CBTForceUnitTestHarnessOptions { @@ -176,7 +204,8 @@ export interface CBTForceUnitTestHarnessOptions { allowExtremeRange?: boolean; readOnly?: boolean; hasDirectInventory?: boolean; - computeEntryState?: (entry: MountedEquipment) => CBTForceUnitTestEntryState; + getEquipmentStatus?: (entry: MountedEquipment) => MountedEquipmentStatus; + getEquipmentToHit?: (entry: MountedEquipment) => MountedEquipmentToHit; isEquipmentUnavailable?: (source: MountedEquipment | CriticalSlot, location?: string) => boolean; applyInventoryControlDisplayEffects?: (entry: MountedEquipment, display: InventoryControlDisplayData) => InventoryControlDisplayData; } @@ -262,11 +291,15 @@ export class CBTForceUnitTestHarness { } }; + const getEntryState = (entry: MountedEquipment) => this.entryStates.get(entry) ?? defaultEntryState(entry); + const getEquipmentStatus = (entry: MountedEquipment) => options.getEquipmentStatus?.(entry) + ?? getEntryState(entry).status; + const getEquipmentToHit = (entry: MountedEquipment) => options.getEquipmentToHit?.(entry) + ?? getEntryState(entry).toHit; const rules = { - computeAllEntryStates: () => this.entryStates, - computeEntryState: (entry: MountedEquipment) => this.entryStates.get(entry) - ?? options.computeEntryState?.(entry) - ?? defaultEntryState(entry), + getEquipmentStatus, + getEquipmentToHits: () => new Map(Array.from(this.entryStates, ([entry, state]) => [entry, state.toHit])), + getEquipmentToHit, heatDissipation: () => options.tracksHeat === false ? null : { totalPips: 10, healthyPips: 10, @@ -405,11 +438,12 @@ export function createCBTForceUnitTestHarness(options: CBTForceUnitTestHarnessOp } function defaultEntryState(entry: MountedEquipment): CBTForceUnitTestEntryState { - return { - isDamaged: entry.committedDestroyed(), - isDisabled: entry.states.get(ENTRY_DISABLED_STATE_KEY) === ENTRY_DISABLED_STATE_VALUE, - hitMod: 0 - }; + const status = entry.committedDestroyed() + ? 'destroyed' + : entry.states.get(ENTRY_DISABLED_STATE_KEY) === ENTRY_DISABLED_STATE_VALUE + ? 'disabled' + : 'available'; + return createTestEquipmentState(status); } function defaultEquipmentUnavailable(source: MountedEquipment | CriticalSlot): boolean { diff --git a/src/app/utils/inventory-control.util.ts b/src/app/utils/inventory-control.util.ts index 0f1a8789a..3f9bd0e4f 100644 --- a/src/app/utils/inventory-control.util.ts +++ b/src/app/utils/inventory-control.util.ts @@ -13,7 +13,7 @@ import type { UnitComponent } from '../models/units.model'; import type { InventoryControlRuntimeEntryState, InventoryControlRuntimeRangeKey, InventoryControlRuntimeTarget, InventoryControlRuntimeTargetId } from '../models/inventory-control-runtime-state.model'; import type { ToHitAdjustment, ToHitModifierBreakdownEntry, ToHitResolution } from '../models/rules/game-rules'; import { FIELD_GUN_LOCATION, InfantryRules } from '../models/rules/infantry-rules'; -import type { MountedEquipmentRuleState } from '../models/rules/unit-type-rules'; +import type { MountedEquipmentToHit } from '../models/rules/unit-type-rules'; import { getBattleArmorTrooperNumber } from '../models/battle-armor-location.model'; import { formatBattleArmorTrooperLocation, @@ -190,12 +190,12 @@ export function getInventoryControlGroups( equipmentCatalog: EquipmentRegistry, rules: InventoryControlRules = {} ): InventoryControlGroup[] { - const entryStates = getEntryStates(unit); + const equipmentToHits = unit.rules.getEquipmentToHits(); const ammoSources = getAmmoSources(unit, equipmentCatalog); const rows = unit.getInventory() .map((entry, index) => { const locationLock = getBattleArmorWeaponLocation(entry); - return buildInventoryControlRow(entry, index, entryStates, ammoSources, rules, equipmentCatalog, { + return buildInventoryControlRow(entry, index, equipmentToHits, ammoSources, rules, equipmentCatalog, { locationLock, destroyed: locationLock ? unit.isEquipmentUnavailable(entry, locationLock) @@ -448,7 +448,7 @@ function compareRows(a: InventoryControlRow, b: InventoryControlRow, groupId: In function buildInventoryControlRow( entry: MountedEquipment, originalIndex: number, - entryStates: Map, + equipmentToHits: Map, ammoSources: AmmoSource[], rules: InventoryControlRules, equipmentCatalog: EquipmentRegistry, @@ -462,10 +462,11 @@ function buildInventoryControlRow( if (entry.el && !entry.el.classList.contains('inventoryEntry') && !fieldGunComponent && !linkedWeaponEnhancement) return null; if (!entry.el && !fieldGunComponent && !hasModelDisplay) return null; - const state = entryStates.get(entry) ?? entry.ruleState(); - const destroyed = options.destroyed ?? state.isDamaged; + const status = unitRules.getEquipmentStatus(entry); + const toHit = equipmentToHits.get(entry) ?? unitRules.getEquipmentToHit(entry); + const destroyed = options.destroyed ?? status === 'destroyed'; const disabled = entry.isActionUnavailable() - || state.isDisabled + || status === 'disabled' || rules.isSelectable?.(entry) === false; const category = getEntryCategory(entry); const { modes, modifiers } = readInventoryControlModesAndModifiers(entry); @@ -474,8 +475,8 @@ function buildInventoryControlRow( const ammo = getInventoryControlAmmoSummary(entry, ammoSources, selectedMode, equipmentCatalog, rules.matchesAmmo, options.locationLock); const selectedAmmoOption = resolveInventoryControlSelectedAmmoOption(ammo.options, entry.owner.getInventoryControlEntryAmmoOption?.(entry.id)); const selectedAmmo = selectedAmmoOption?.ammo ?? null; - const additionalHitModifier = state?.hitMod ?? 0; - const hitModifierBreakdown = state?.hitModifierBreakdown ?? []; + const additionalHitModifier = toHit.modifier; + const hitModifierBreakdown = toHit.modifiers; const hitResolution = resolveInventoryControlHitModifier( entry, additionalHitModifier, @@ -577,9 +578,6 @@ function getBattleArmorWeaponLocation(entry: MountedEquipment): string | undefin return Array.from(entry.locations ?? []).find(location => getBattleArmorTrooperNumber(location) !== null); } -function getEntryStates(unit: CBTForceUnit): Map { - return unit.rules.computeAllEntryStates(); -} function getEntryCategory(entry: MountedEquipment): InventoryControlGroupId { if (entry.isPhysicalWeapon()) return 'physical'; diff --git a/src/app/utils/inventory-target-number.util.spec.ts b/src/app/utils/inventory-target-number.util.spec.ts index 0be662fc2..ae4ba86dd 100644 --- a/src/app/utils/inventory-target-number.util.spec.ts +++ b/src/app/utils/inventory-target-number.util.spec.ts @@ -5,11 +5,12 @@ import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; +import { createTestEquipmentRules } from '../testing/unit-test-helpers'; import type { InventoryTargetNumberInput } from './inventory-target-number.util'; import { inventoryTargetNumberBreakdown, inventoryTargetNumberState, inventoryTargetRangeSelection } from './inventory-target-number.util'; function artilleryInput(distance: number, gameRules: CBTGameRules = CORE_2026_GAME_RULES): InventoryTargetNumberInput { - const owner = { rules: { computeEntryState: () => ({ isDamaged: false, isDisabled: false, hitMod: 0 }) } } as never; + const owner = { rules: createTestEquipmentRules() } as never; const equipment = new WeaponEquipment({ id: 'ArrowIV', name: 'Arrow IV', @@ -46,7 +47,7 @@ function aeroInput( ): InventoryTargetNumberInput { const owner = { getUnit: () => ({ type: 'Aero' }), - rules: { computeEntryState: () => ({ isDamaged: false, isDisabled: false, hitMod: 0 }) } + rules: createTestEquipmentRules() } as never; const equipment = new WeaponEquipment({ id: 'AeroWeapon', @@ -75,7 +76,7 @@ function aeroInput( } function c3LaserInput(actualDistance: number, c3Distance: number, allowExtremeRange = false): InventoryTargetNumberInput { - const owner = { rules: { computeEntryState: () => ({ isDamaged: false, isDisabled: false, hitMod: 0 }) } } as never; + const owner = { rules: createTestEquipmentRules() } as never; const equipment = new WeaponEquipment({ id: 'ERLargeLaser', name: 'ER Large Laser', From 22b407d83b40ec53833dbb0282936d1b943bd068 Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 9 Aug 2026 09:59:02 +0200 Subject: [PATCH 08/12] solved cycling references and reworked equipment status system --- .../c3-network-dialog.component.spec.ts | 17 +- .../c3-network-dialog.component.ts | 5 +- .../ammo-loadout-panel.component.spec.ts | 48 +- .../ammo-loadout-panel.component.ts | 4 +- .../equipment-dialog.component.spec.ts | 29 +- .../equipment-dialog.component.ts | 6 +- .../equipment-dialog.model.ts | 18 +- .../weapons-equipment-panel.component.html | 7 +- .../weapons-equipment-panel.component.scss | 10 +- .../weapons-equipment-panel.component.spec.ts | 289 +++++--- .../weapons-equipment-panel.component.ts | 114 ++-- .../page-interaction-overlay.component.scss | 18 + .../page-interaction-overlay.component.ts | 10 +- .../page-turn-summary-panel.component.ts | 25 +- .../svg-interaction.service.spec.ts | 63 +- .../page-viewer/svg-interaction.service.ts | 66 +- .../unit-block/unit-block.component.ts | 8 +- .../equipment-handlers/apollo.handler.spec.ts | 121 +++- src/app/equipment-handlers/apollo.handler.ts | 40 +- .../artemis-v.handler.spec.ts | 65 +- .../equipment-handlers/artemis-v.handler.ts | 35 +- .../equipment-handlers/atm.handler.spec.ts | 9 +- src/app/equipment-handlers/atm.handler.ts | 8 +- .../base/cycle-mode.handler.ts | 10 +- .../base/multi-mode.handler.spec.ts | 70 ++ .../base/multi-mode.handler.ts | 13 +- .../equipment-handlers/base/toggle.handler.ts | 9 +- .../bombast-laser.handler.spec.ts | 135 ++-- .../bombast-laser.handler.ts | 32 +- .../c3-emergency-master.handler.spec.ts | 126 ++-- .../c3-emergency-master.handler.ts | 19 +- src/app/equipment-handlers/c3.handler.ts | 14 +- .../disabled-equipment.handler.spec.ts | 69 +- .../disabled-equipment.handler.ts | 10 +- src/app/equipment-handlers/ecm.handler.ts | 9 +- .../escalatingfailure.handler.ts | 18 +- .../equipment-handlers/hag.handler.spec.ts | 31 +- src/app/equipment-handlers/hag.handler.ts | 13 +- .../inventory-mode.handler.ts | 11 +- .../laser-insulator.handler.spec.ts | 34 +- .../laser-insulator.handler.ts | 12 +- .../equipment-handlers/masc.handler.spec.ts | 137 ++-- src/app/equipment-handlers/masc.handler.ts | 16 +- .../equipment-handlers/mml.handler.spec.ts | 9 +- src/app/equipment-handlers/mml.handler.ts | 8 +- .../ppc-capacitor.handler.spec.ts | 280 +++++++- .../ppc-capacitor.handler.ts | 157 ++++- .../risc-laser-pulse-module.handler.spec.ts | 26 +- .../risc-laser-pulse-module.handler.ts | 37 +- .../stealth.handler.spec.ts | 23 +- .../uacjamming.handler.spec.ts | 49 +- .../vibroblade.handler.spec.ts | 63 +- .../equipment-handlers/vibroblade.handler.ts | 11 +- .../equipment-handlers/weapon-ammo.handler.ts | 24 +- src/app/models/cbt-force-unit-c3.spec.ts | 68 +- src/app/models/cbt-force-unit-state.model.ts | 5 + src/app/models/cbt-force-unit.model.spec.ts | 632 ++++++++++++++++-- src/app/models/cbt-force-unit.model.ts | 418 ++++++++++-- ...bt-inventory-control-runtime.model.spec.ts | 270 ++++++++ .../cbt-inventory-control-runtime.model.ts | 29 +- src/app/models/equipment-status.model.spec.ts | 20 + src/app/models/equipment-status.model.ts | 45 ++ src/app/models/equipment.model.spec.ts | 5 +- ...nventory-component-reference.model.spec.ts | 10 +- .../inventory-component-reference.model.ts | 19 +- .../inventory-control-runtime-state.model.ts | 99 ++- .../models/mounted-equipment.model.spec.ts | 29 - src/app/models/mounted-equipment.model.ts | 32 +- src/app/models/rules/aero-rules.spec.ts | 22 +- src/app/models/rules/aimed-shot.util.ts | 2 +- src/app/models/rules/game-rules.spec.ts | 111 ++- src/app/models/rules/game-rules.ts | 31 +- src/app/models/rules/infantry-rules.spec.ts | 55 +- src/app/models/rules/infantry-rules.ts | 41 +- src/app/models/rules/mek-rules.spec.ts | 596 +++++++++++------ src/app/models/rules/mek-rules.ts | 108 +-- src/app/models/rules/protomek-rules.spec.ts | 2 +- src/app/models/rules/tw-rules.spec.ts | 8 +- src/app/models/rules/tw-rules.ts | 10 +- src/app/models/rules/unit-type-rules.ts | 129 ++-- src/app/models/rules/vehicle-rules.spec.ts | 178 +++-- src/app/models/rules/vehicle-rules.ts | 49 +- src/app/models/turn-state.model.spec.ts | 20 +- ...pment-interaction-registry.service.spec.ts | 360 ++++++++-- .../equipment-interaction-registry.service.ts | 200 ++++-- src/app/services/force-builder.service.ts | 8 +- src/app/services/unit-svg-infantry.service.ts | 4 +- src/app/services/unit-svg-mek.service.ts | 32 +- src/app/services/unit-svg-vehicle.service.ts | 26 +- src/app/services/unit-svg.service.ts | 32 +- src/app/testing/unit-test-helpers.spec.ts | 181 ++++- src/app/testing/unit-test-helpers.ts | 290 +++++--- src/app/utils/ammo-interaction.util.spec.ts | 78 ++- src/app/utils/ammo-interaction.util.ts | 39 +- src/app/utils/cbtprint.util.ts | 4 +- .../utils/inventory-control-ammo.util.spec.ts | 157 ++++- src/app/utils/inventory-control.util.ts | 284 ++++++-- .../inventory-target-number.util.spec.ts | 62 +- src/app/utils/inventory-target-number.util.ts | 22 +- src/app/utils/mul-file.util.spec.ts | 2 + 100 files changed, 5281 insertions(+), 2033 deletions(-) create mode 100644 src/app/equipment-handlers/base/multi-mode.handler.spec.ts create mode 100644 src/app/models/cbt-inventory-control-runtime.model.spec.ts create mode 100644 src/app/models/equipment-status.model.spec.ts create mode 100644 src/app/models/equipment-status.model.ts diff --git a/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts b/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts index fcf8339b9..24e0e1a0f 100644 --- a/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts +++ b/src/app/components/c3-network-dialog/c3-network-dialog.component.spec.ts @@ -10,7 +10,6 @@ import type { Force } from '../../models/force.model'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; import type { SerializedC3NetworkGroup } from '../../models/force-serialization'; import { MountedEquipment } from '../../models/mounted-equipment.model'; -import { createTestEquipmentRules } from '../../testing/unit-test-helpers'; import { C3Capabilities, C3_FLAGS, @@ -85,17 +84,19 @@ function c3UnitWithComponents(id: string, componentFlags: readonly (readonly str && !destroyedComponents().has(index) && !actionUnavailableComponents().has(index), isC3Jammed: () => jammed(), - isEquipmentActionUnavailable: (entry: MountedEquipment) => { + canPerformEquipmentAction: (entry: MountedEquipment) => { const index = inventory.indexOf(entry); - return index >= 0 && actionUnavailableComponents().has(index); + return index < 0 || !destroyedComponents().has(index) && !actionUnavailableComponents().has(index); + }, + getEquipmentStatus: (entry: MountedEquipment) => ( + destroyedComponents().has(inventory.indexOf(entry)) ? 'destroyed' : 'available' + ), + isEquipmentOperational: (entry: MountedEquipment) => { + const index = inventory.indexOf(entry); + return index >= 0 && !destroyedComponents().has(index); }, rules: { calculateC3Tax: () => 0, - ...createTestEquipmentRules({ - getEquipmentStatus: (entry: MountedEquipment) => ( - destroyedComponents().has(inventory.indexOf(entry)) ? 'destroyed' : 'available' - ), - }), }, } as unknown as CBTForceUnit; inventory = componentFlags.map((flags, index) => new MountedEquipment({ diff --git a/src/app/components/c3-network-dialog/c3-network-dialog.component.ts b/src/app/components/c3-network-dialog/c3-network-dialog.component.ts index 8af01981e..d849a40cc 100644 --- a/src/app/components/c3-network-dialog/c3-network-dialog.component.ts +++ b/src/app/components/c3-network-dialog/c3-network-dialog.component.ts @@ -944,8 +944,9 @@ export class C3NetworkDialogComponent implements AfterViewInit { return [...componentIndexes].every(index => { const component = runtime.capability(node.unit.id)?.component(index); if (!component) return false; - return component.mount?.isActionUnavailable() - ?? !node.unit.isC3EndpointOperational(index, component); + return component.mount + ? !component.mount.owner.canPerformEquipmentAction(component.mount, 'configure-network') + : !node.unit.isC3EndpointOperational(index, component); }); } diff --git a/src/app/components/equipment-dialog/ammo-loadout-panel.component.spec.ts b/src/app/components/equipment-dialog/ammo-loadout-panel.component.spec.ts index b28359960..587643534 100644 --- a/src/app/components/equipment-dialog/ammo-loadout-panel.component.spec.ts +++ b/src/app/components/equipment-dialog/ammo-loadout-panel.component.spec.ts @@ -6,8 +6,14 @@ import { TestBed } from '@angular/core/testing'; import { AmmoEquipment } from '../../models/equipment.model'; import { EquipmentRegistry } from '../../models/equipment-lookup'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; +import { MountedEquipment } from '../../models/mounted-equipment.model'; import type { CriticalSlot } from '../../models/force-serialization'; -import type { HandlerContext } from '../../services/equipment-interaction-registry.service'; +import { + createHandlerCommandContext, + type HandlerCommandContext, + type HandlerDialogsService, + type HandlerToastService, +} from '../../services/equipment-interaction-registry.service'; import { AmmoLoadoutPanelComponent, type AmmoLoadoutPanelData } from './ammo-loadout-panel.component'; import type { AmmoControlEntry } from '../../utils/ammo-interaction.util'; @@ -29,8 +35,11 @@ function createCritEntry(params: { owner: Pick; }): AmmoControlEntry { const owner = params.owner as CBTForceUnit; - const isEquipmentUnavailable: CBTForceUnit['isEquipmentUnavailable'] = (source: CriticalSlot) => !!source.destroyed; - owner.isEquipmentUnavailable ??= isEquipmentUnavailable; + owner.getEquipmentStatus ??= source => source instanceof MountedEquipment && source.committedDestroyed() + || !(source instanceof MountedEquipment) && !!source.destroyed + ? 'destroyed' + : 'available'; + owner.isEquipmentOperational ??= source => owner.getEquipmentStatus(source) === 'available'; const source = { id: `${params.ammo.internalName}@${params.loc}#${params.slot}`, name: params.ammo.internalName, @@ -76,6 +85,17 @@ function createToastServiceMock() { }; } +function createCommandContext( + equipmentCatalog = new EquipmentRegistry({}), + toastService: HandlerToastService = createToastServiceMock(), +): HandlerCommandContext { + const dialogsService = jasmine.createSpyObj( + 'HandlerDialogsService', + ['createDialog', 'showError', 'showNoticeHtml'], + ); + return createHandlerCommandContext(equipmentCatalog, toastService, dialogsService); +} + describe('AmmoLoadoutPanelComponent', () => { function configurePanel(data: AmmoLoadoutPanelData): AmmoLoadoutPanelComponent { TestBed.configureTestingModule({ @@ -103,7 +123,7 @@ describe('AmmoLoadoutPanelComponent', () => { const data: AmmoLoadoutPanelData = { entries: liveEntries, getEntries: () => liveEntries, - context: {} as HandlerContext, + context: createCommandContext(), }; const component = configurePanel(data); @@ -134,7 +154,7 @@ describe('AmmoLoadoutPanelComponent', () => { } as unknown as Pick; const data: AmmoLoadoutPanelData = { entries: [createCritEntry({ loc: 'LT', slot: 0, ammo: standardAmmo, owner })], - context: {} as HandlerContext, + context: createCommandContext(), }; TestBed.configureTestingModule({ @@ -174,7 +194,7 @@ describe('AmmoLoadoutPanelComponent', () => { createCritEntry({ loc: 'RT', slot: 2, ammo: standardAmmo, owner }), createCritEntry({ loc: 'CT', slot: 3, ammo: standardAmmo, destroyed: true, owner }), ], - context: {} as HandlerContext, + context: createCommandContext(), }; TestBed.configureTestingModule({ @@ -216,7 +236,7 @@ describe('AmmoLoadoutPanelComponent', () => { createCritEntry({ loc: 'RT', slot: 1, ammo: standardAmmo, owner }), createCritEntry({ loc: 'RT', slot: 2, ammo: standardAmmo, destroyed: true, owner }), ], - context: {} as HandlerContext, + context: createCommandContext(), }; TestBed.configureTestingModule({ @@ -252,12 +272,10 @@ describe('AmmoLoadoutPanelComponent', () => { const destroyedEntry = createCritEntry({ loc: 'LT', slot: 1, ammo: standardAmmo, owner, destroyed: true }); const data: AmmoLoadoutPanelData = { entries: [activeEntry, destroyedEntry], - context: { - dataService: { - getEquipmentRegistry: () => new EquipmentRegistry({ [standardAmmo.internalName]: standardAmmo }), - }, - toastService: createToastServiceMock(), - } as unknown as HandlerContext, + context: createCommandContext( + new EquipmentRegistry({ [standardAmmo.internalName]: standardAmmo }), + createToastServiceMock(), + ), }; TestBed.configureTestingModule({ @@ -294,7 +312,7 @@ describe('AmmoLoadoutPanelComponent', () => { const data: AmmoLoadoutPanelData = { entries: [changedEntry, remainingEntry], getEntries: () => [changedEntry, remainingEntry], - context: {} as HandlerContext, + context: createCommandContext(), }; const component = configurePanel(data); const group = component.groups()[0]; @@ -310,4 +328,4 @@ describe('AmmoLoadoutPanelComponent', () => { expect(rebuiltGroups.length).toBe(2); expect(rebuiltGroups.every(rebuiltGroup => component.isExpanded(rebuiltGroup))).toBeTrue(); }); -}); \ No newline at end of file +}); diff --git a/src/app/components/equipment-dialog/ammo-loadout-panel.component.ts b/src/app/components/equipment-dialog/ammo-loadout-panel.component.ts index 820783ee2..4021db3db 100644 --- a/src/app/components/equipment-dialog/ammo-loadout-panel.component.ts +++ b/src/app/components/equipment-dialog/ammo-loadout-panel.component.ts @@ -4,13 +4,13 @@ import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; import type { CBTInventoryControlRuntime } from '../../models/cbt-inventory-control-runtime.model'; -import type { HandlerContext } from '../../services/equipment-interaction-registry.service'; +import type { HandlerCommandContext } from '../../services/equipment-interaction-registry.service'; import type { AmmoControlEntry, AmmoControlGroup, AmmoControlGroupLocation } from '../../utils/ammo-interaction.util'; import { changeAmmoEntryRemaining, changeAmmoGroupRemaining, getAmmoControlGroups, getAmmoEntryRemaining, getAmmoGroupRemaining, setAmmoEntry, setAmmoGroup } from '../../utils/ammo-interaction.util'; export interface AmmoLoadoutPanelData { entries: AmmoControlEntry[]; - context: HandlerContext; + context: HandlerCommandContext; readOnly?: boolean; getEntries?: () => AmmoControlEntry[]; inventoryControl?: CBTInventoryControlRuntime; diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts b/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts index 9ea256b29..61d443efe 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts +++ b/src/app/components/equipment-dialog/equipment-dialog.component.spec.ts @@ -12,6 +12,12 @@ import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; import { MountedEquipment } from '../../models/mounted-equipment.model'; import { KeyboardShortcutService } from '../../services/keyboard-shortcut.service'; import { OverlayManagerService } from '../../services/overlay-manager.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + type HandlerDialogsService, + type HandlerToastService, +} from '../../services/equipment-interaction-registry.service'; import { createCBTForceUnitTestHarness } from '../../testing/unit-test-helpers'; import { EquipmentDialogComponent } from './equipment-dialog.component'; import type { EquipmentDialogContext, EquipmentDialogData } from './equipment-dialog.model'; @@ -86,19 +92,26 @@ function createDialog(data: EquipmentDialogData) { } function createContext(): EquipmentDialogContext { + const equipmentCatalog = new EquipmentRegistry({}); + const toastService = jasmine.createSpyObj( + 'HandlerToastService', + ['showToast', 'toasts'], + ); + toastService.toasts.and.returnValue([]); + const dialogsService = jasmine.createSpyObj( + 'HandlerDialogsService', + ['createDialog', 'showNoticeHtml', 'showError'], + ); return { - toastService: { showToast: jasmine.createSpy('showToast') }, - dialogsService: { showNoticeHtml: jasmine.createSpy('showNoticeHtml').and.resolveTo(), showError: jasmine.createSpy('showError').and.resolveTo() }, - dataService: { - getEquipmentRegistry: () => new EquipmentRegistry({}), - }, registry: { getChoices: () => [], handleSelection: () => false, afterInventoryControlFire: () => undefined, inventoryControlRules: () => ({}) - } - } as unknown as EquipmentDialogContext; + }, + queryContext: createHandlerQueryContext(equipmentCatalog), + commandContext: createHandlerCommandContext(equipmentCatalog, toastService, dialogsService), + } satisfies EquipmentDialogContext; } describe('EquipmentDialogComponent', () => { @@ -152,4 +165,4 @@ describe('EquipmentDialogComponent', () => { expect(footerCenter.textContent).toContain('DISMISS'); expect(footerCenter.querySelector('button[aria-label="Reset"]')).not.toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/src/app/components/equipment-dialog/equipment-dialog.component.ts b/src/app/components/equipment-dialog/equipment-dialog.component.ts index 61f910a17..8f09307e2 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.component.ts +++ b/src/app/components/equipment-dialog/equipment-dialog.component.ts @@ -179,7 +179,7 @@ export class EquipmentDialogComponent { ammoPanelData(unit: CBTForceUnit): AmmoLoadoutPanelData { return { entries: this.ammoEntries(unit), - context: this.data.context, + context: this.data.context.commandContext, readOnly: this.readOnly(unit), getEntries: () => this.ammoEntries(unit), inventoryControl: unit.inventoryControl @@ -378,7 +378,7 @@ export class EquipmentDialogComponent { } private ammoEntries(unit: CBTForceUnit) { - return getAmmoControlEntriesForUnitWeapons(unit, this.data.context.dataService.getEquipmentRegistry()); + return getAmmoControlEntriesForUnitWeapons(unit, this.data.context.queryContext.equipmentCatalog); } private closeUnitOverlays(unitId: string): void { @@ -396,4 +396,4 @@ export class EquipmentDialogComponent { private psrWarningOverlayKey(unitId = this.unit().id): string { return `psrWarning-${unitId}`; } -} \ No newline at end of file +} diff --git a/src/app/components/equipment-dialog/equipment-dialog.model.ts b/src/app/components/equipment-dialog/equipment-dialog.model.ts index 2be226064..1eca090e2 100644 --- a/src/app/components/equipment-dialog/equipment-dialog.model.ts +++ b/src/app/components/equipment-dialog/equipment-dialog.model.ts @@ -5,22 +5,22 @@ import type { Signal } from '@angular/core'; import type { CBTForceUnit } from '../../models/cbt-force-unit.model'; import type { MountedEquipment } from '../../models/mounted-equipment.model'; -import type { HandlerChoice, HandlerContext } from '../../services/equipment-interaction-registry.service'; +import type { HandlerChoice, HandlerCommandContext, HandlerQueryContext } from '../../services/equipment-interaction-registry.service'; import type { InventoryControlRules } from '../../utils/inventory-control.util'; export type EquipmentDialogTab = 'weapons' | 'ammo'; export interface EquipmentDialogRegistry { - getChoices(entry: MountedEquipment, context: HandlerContext): HandlerChoice[]; - handleSelection(entry: MountedEquipment, choice: HandlerChoice, context: HandlerContext): boolean | Promise; - afterInventoryControlFire(entry: MountedEquipment, context: HandlerContext): void | Promise; - onEndTurn?(entry: MountedEquipment, context: HandlerContext): void; - canPerformAimedShot(entry: MountedEquipment, context: HandlerContext): boolean; - inventoryControlRules(context: HandlerContext): InventoryControlRules; + getChoices(entry: MountedEquipment, context: HandlerQueryContext): HandlerChoice[]; + handleSelection(entry: MountedEquipment, choice: HandlerChoice, context: HandlerCommandContext): boolean | Promise; + afterInventoryControlFire(entry: MountedEquipment): void | Promise; + inventoryControlRules(context: HandlerQueryContext): InventoryControlRules; } -export interface EquipmentDialogContext extends HandlerContext { +export interface EquipmentDialogContext { registry: EquipmentDialogRegistry; + queryContext: HandlerQueryContext; + commandContext: HandlerCommandContext; } export interface EquipmentDialogData { @@ -31,4 +31,4 @@ export interface EquipmentDialogData { context: EquipmentDialogContext; readOnly?: boolean; initialTab?: EquipmentDialogTab; -} \ No newline at end of file +} diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.html b/src/app/components/equipment-dialog/weapons-equipment-panel.component.html index e4623d2e4..80b52eefb 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.html +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.html @@ -77,7 +77,8 @@

(cdkDropListDropped)="drop($event, group)"> @for (row of group.rows; track row.id) { @let rowTarget = targetState(row); -
+ @let rowPresentation = rowPresentationState(row); +
@@ -112,7 +113,9 @@

{{ row.display.name }} @for (modifier of row.modifiers; track modifier.name) { - {{ modifier.name }} + {{ modifier.name }}{{ modifier.status === 'destroyed' ? ' Destroyed' : modifier.status === 'disabled' ? ' Disabled' : '' }} } @if (modeChoice(row); as choice) { @if ((choice.choices?.length ?? 0) > 1 && !choice.disabled && !readOnly()) { diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss b/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss index eed50f498..b56f98024 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss @@ -211,6 +211,10 @@ color: var(--text-color-secondary); } +.weapon-equipment-row.operation-disabled-entry { + color: var(--text-color-secondary); +} + .weapon-equipment-row.disabled-entry .name-cell > span:first-child { text-decoration-line: line-through; } @@ -236,6 +240,10 @@ text-decoration-line: line-through; } +.modifier.disabled { + color: var(--text-color-secondary); +} + .drag-cell, .select-cell, .select-header { @@ -856,4 +864,4 @@ width: auto; min-width: 32px; } -} \ No newline at end of file +} diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts index 833070560..290a7231d 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts @@ -8,6 +8,7 @@ import { TestBed } from '@angular/core/testing'; import { AmmoEquipment, WeaponEquipment, MiscEquipment, type AmmoType, type EquipmentMap } from '../../models/equipment.model'; import { INVENTORY_CONTROL_TARGET_COLORS } from '../../models/inventory-control-runtime-state.model'; import type { UnitModifierBreakdownEntry } from '../../models/rules/unit-type-rules'; +import type { EquipmentStatus } from '../../models/equipment-status.model'; import { MountedAmmo, MountedEquipment } from '../../models/mounted-equipment.model'; import { type CriticalSlot } from '../../models/force-serialization'; import { InventoryModeHandler } from '../../equipment-handlers/inventory-mode.handler'; @@ -19,14 +20,19 @@ import { ArtemisVHandler } from '../../equipment-handlers/artemis-v.handler'; import { APOLLO_MODE_STATE, APOLLO_SATURATION_MODE, ApolloHandler } from '../../equipment-handlers/apollo.handler'; import { LaserInsulatorHandler } from '../../equipment-handlers/laser-insulator.handler'; import { RISC_LASER_PULSE_MODE, RiscLaserPulseModuleHandler } from '../../equipment-handlers/risc-laser-pulse-module.handler'; -import { EquipmentInteractionRegistryService, type EquipmentInteractionHandler } from '../../services/equipment-interaction-registry.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionRegistryService, + type EquipmentInteractionHandler, +} from '../../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE, inventoryControlSortKey, getInventoryControlGroups, selectInventoryControlEntry, type InventoryControlDisplayData } from '../../utils/inventory-control.util'; import { WeaponsEquipmentPanelComponent } from './weapons-equipment-panel.component'; import type { EquipmentDialogContext } from './equipment-dialog.model'; import type { MotiveModes } from '../../models/motiveModes.model'; import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../../models/rules/unit-type-rules'; -import { ATTACK_MOVEMENT_MODIFIER_BREAKDOWN_PRIORITY, CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules, type C3DegradationSource, SKILL_BREAKDOWN_PRIORITY } from '../../models/rules/game-rules'; -import { createCBTForceUnitTestHarness, createTestEquipmentState, type CBTForceUnitTestEntryState, type TestUnitOverrides } from '../../testing/unit-test-helpers'; +import { ATTACK_MOVEMENT_MODIFIER_BREAKDOWN_PRIORITY, CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules, type C3DegradationSource, type ToHitModifierBreakdownEntry, SKILL_BREAKDOWN_PRIORITY } from '../../models/rules/game-rules'; +import { createCBTForceUnitTestHarness, type TestUnitOverrides } from '../../testing/unit-test-helpers'; import { getVibrobladeMode, VIBROBLADE_MODE_STATE, VIBROBLADE_ON_MODE, VibrobladeHandler } from '../../equipment-handlers/vibroblade.handler'; import { EquipmentFlag } from '../../models/equipment-flags.type'; import { EquipmentRegistry } from '../../models/equipment-lookup'; @@ -104,6 +110,7 @@ function entry(params: { } interface CreateComponentOptions { + equipmentToHitModifiers?: ReadonlyMap; readOnly?: boolean; hasDirectInventory?: boolean; conditions?: readonly string[]; @@ -123,6 +130,7 @@ interface CreateComponentOptions { gameRules?: CBTGameRules; unit?: TestUnitOverrides; handlers?: EquipmentInteractionHandler[]; + equipmentStatusesAtLocation?: ReadonlyMap>; applyUnitDisplayEffects?: (entry: MountedEquipment, display: InventoryControlDisplayData) => InventoryControlDisplayData; } @@ -130,7 +138,7 @@ function createComponent( entries: MountedEquipment[], equipmentMap: EquipmentMap = {}, critSlots: CriticalSlot[] = [], - entryStates = new Map(), + equipmentStatuses = new Map(), options: CreateComponentOptions = {} ) { const handlers = [ @@ -168,7 +176,9 @@ function createComponent( conditions: options.conditions, equipment: equipmentMap, criticalSlots: critSlots, - entryStates, + equipmentStatuses, + equipmentStatusesAtLocation: options.equipmentStatusesAtLocation, + equipmentToHitModifiers: options.equipmentToHitModifiers, heat: { next: options.heatNext }, tracksHeat: options.tracksHeat, heatDissipation: options.heatDissipation, @@ -194,17 +204,15 @@ function createComponent( spyOn(unitHarness.turnState, 'addFiredHeat').and.callThrough(); const registry = new EquipmentInteractionRegistryService().getRegistry(); handlers.forEach(handler => registry.register(handler)); + const queryContext = createHandlerQueryContext(unitHarness.equipmentRegistry); const context = { - toastService, - dialogsService, - dataService: { - getEquipmentRegistry: () => unitHarness.equipmentRegistry, - }, - registry - } as unknown as EquipmentDialogContext; - const equipmentRules = registry.inventoryControlRules(context); + registry, + queryContext, + commandContext: createHandlerCommandContext(unitHarness.equipmentRegistry, toastService, dialogsService), + } satisfies EquipmentDialogContext; + const equipmentRules = registry.inventoryControlRules(context.queryContext); unitHarness - .setToHitAdjustments((entry, selectedAmmo) => registry.getToHitAdjustments(entry, context, selectedAmmo)) + .setToHitAdjustments((entry, selectedAmmo) => registry.getToHitAdjustments(entry, context.queryContext, selectedAmmo)) .setInventoryControlRules({ ...equipmentRules, applyDisplayEffects: (entry, display, displayOptions) => { @@ -275,16 +283,18 @@ describe('WeaponsEquipmentPanelComponent', () => { it('shows modifiers and tooltips for VS physical attacks', () => { const charge = entry({ id: 'Charge', intrinsicPhysicalAttack: true }); const deathFromAbove = entry({ id: 'Death From Above', intrinsicPhysicalAttack: true }); - const entryStates = new Map([ - [charge, createTestEquipmentState('available', [ - { label: 'Damaged actuator', modifier: 1, weakened: true }, - { label: 'Prone', modifier: 2 } - ])], - [deathFromAbove, createTestEquipmentState('available', [ + const equipmentToHitModifiers = new Map([ + [charge, [ + { label: 'Damaged actuator', modifier: 1, weakened: true }, + { label: 'Prone', modifier: 2 } + ]], + [deathFromAbove, [ { label: 'Dedicated Pilot', modifier: -1 } - ])] + ]] ]); - const { component, fixture, unit } = createComponent([charge, deathFromAbove], {}, [], entryStates); + const { component, fixture, unit } = createComponent( + [charge, deathFromAbove], {}, [], new Map(), { equipmentToHitModifiers } + ); const rows = component.groups().find(group => group.id === 'physical')!.rows; const hitCells = Array.from(fixture.nativeElement.querySelectorAll('.hit-cell')) as HTMLElement[]; @@ -423,7 +433,9 @@ describe('WeaponsEquipmentPanelComponent', () => { const ammoBin = entry({ id: 'ac2-ammo', equipment: ac2Ammo, totalAmmo: 10, consumed: 0, locations: new Set(['RT']) }); const { unit } = createCBTForceUnitTestHarness({ components: [weaponEntry, ammoBin], - isEquipmentUnavailable: source => source === ammoBin + equipmentStatusesAtLocation: new Map([ + [ammoBin, new Map([['RT', 'destroyed' as const]])], + ]), }); const row = getInventoryControlGroups(unit, new EquipmentRegistry({ [ac2Ammo.internalName]: ac2Ammo })).find(group => group.id === 'ranged')!.rows[0]; @@ -472,10 +484,10 @@ describe('WeaponsEquipmentPanelComponent', () => { it('shows rule-damaged inventory rows as destroyed', () => { const laser = entry({ id: 'laser', equipment: weapon('laser'), destroyed: false, el: svgEntry('Laser') }); - const entryStates = new Map([ - [laser, createTestEquipmentState('destroyed', [])] + const equipmentStatuses = new Map([ + [laser, 'destroyed'] ]); - const { component } = createComponent([laser], {}, [], entryStates); + const { component } = createComponent([laser], {}, [], equipmentStatuses); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; @@ -537,12 +549,16 @@ describe('WeaponsEquipmentPanelComponent', () => { l: location })) }, - isEquipmentUnavailable: (source: MountedEquipment | CriticalSlot, loc?: string) => { - const locationUnavailable = (value: string | undefined) => value === 'Trooper 1' || value === 'T1'; - if (!(source instanceof MountedEquipment)) return !!source.destroyed || locationUnavailable(source.loc); - if (source.committedDestroyed()) return true; - return loc ? locationUnavailable(loc) : Array.from(source.locations ?? []).some(locationUnavailable); - } + equipmentStatusesAtLocation: new Map([ + [narcEntries[0], new Map([ + ['Trooper 1', 'destroyed' as const], + ['T1', 'destroyed' as const], + ])], + [ammoEntries[0], new Map([ + ['Trooper 1', 'destroyed' as const], + ['T1', 'destroyed' as const], + ])], + ]), }); const rangedRows = getInventoryControlGroups(unit, new EquipmentRegistry({ [narcAmmo.internalName]: narcAmmo })) @@ -561,17 +577,32 @@ describe('WeaponsEquipmentPanelComponent', () => { it('marks rows disabled from entry state rules', () => { const laser = entry({ id: 'laser', equipment: weapon('laser'), el: svgEntry('Laser') }); - const entryStates = new Map([ - [laser, createTestEquipmentState('disabled', [])] + const equipmentStatuses = new Map([ + [laser, 'disabled'] ]); - const { component } = createComponent([laser], {}, [], entryStates); + const { component } = createComponent([laser], {}, [], equipmentStatuses); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; expect(row.disabled).toBeTrue(); expect(row.destroyed).toBeFalse(); }); - it('marks disabled inventory-only rows disabled without entry state rules', () => { + it('presents an action-restricted available row separately from disabled equipment', () => { + const laser = entry({ id: 'laser', equipment: weapon('laser'), el: svgEntry('Laser') }); + const { component, fixture, unit } = createComponent([laser], {}, [], new Map(), { + conditions: ['shutdown'], + }); + const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; + const renderedRow = fixture.nativeElement.querySelector('.weapon-equipment-row') as HTMLElement; + + expect(unit.getEquipmentStatus(laser)).toBe('available'); + expect(row.disabled).toBeTrue(); + expect(component.rowPresentationState(row)).toBeNull(); + expect(renderedRow.classList.contains('operation-disabled-entry')).toBeTrue(); + expect(renderedRow.classList.contains('disabled-entry')).toBeFalse(); + }); + + it('marks disabled inventory-only rows without mutating attached SVG', () => { const uac = entry({ id: 'uac', equipment: weapon('uac', 'AC_ULTRA'), @@ -583,7 +614,7 @@ describe('WeaponsEquipmentPanelComponent', () => { const row = getInventoryControlGroups(unit, new EquipmentRegistry({})).find(group => group.id === 'ranged')!.rows[0]; expect(row.disabled).toBeTrue(); - expect(uac.el!.classList.contains('disabledInventory')).toBeTrue(); + expect(uac.el!.classList.contains('disabledInventory')).toBeFalse(); }); it('marks direct inventory hits pending before commit', () => { @@ -611,7 +642,7 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(component.rowEffectivelyDestroyed(row)).toBeFalse(); }); - it('repairs destroyed direct inventory entries pending before commit', () => { + it('repairs destroyed direct inventory entries pending before commit and lets a new hit cancel the repair', () => { const broken = entry({ id: 'broken', equipment: weapon('broken'), destroyed: true, el: svgEntry('Broken') }); const { component, fixture, unit } = createComponent([broken]); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; @@ -627,7 +658,47 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(broken.pendingDestroyed()).toBeFalse(); expect(component.rowRepairing(row)).toBeTrue(); expect(component.rowEffectivelyDestroyed(row)).toBeFalse(); - expect((fixture.nativeElement.querySelector('.weapon-equipment-row') as HTMLElement).classList.contains('repairing-entry')).toBeTrue(); + const repairingRow = fixture.nativeElement.querySelector('.weapon-equipment-row') as HTMLElement; + expect(repairingRow.classList.contains('repairing-entry')).toBeTrue(); + expect(repairingRow.classList.contains('disabled-entry')).toBeFalse(); + + expect(component.canMarkDestroyed(row)).toBeTrue(); + component.markDestroyed(row); + + expect(broken.pendingDestroyed()).toBeUndefined(); + expect(component.rowRepairing(row)).toBeFalse(); + expect(component.rowEffectivelyDestroyed(row)).toBeTrue(); + expect(unit.setInventoryEntry).toHaveBeenCalledTimes(2); + }); + + it('lets a destroyed installation location override an inconsistent pending repair', () => { + const broken = entry({ + id: 'broken-location', + equipment: weapon('broken-location'), + destroyed: true, + locations: new Set(['RA']), + el: svgEntry('Broken Location') + }); + broken.setPendingDestroyed(false); + const { component, fixture } = createComponent([broken], {}, [], undefined, { + equipmentStatusesAtLocation: new Map([ + [broken, new Map([['RA', 'destroyed' as const]])], + ]), + }); + const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; + + expect(broken.isRepairing()).toBeTrue(); + expect(component.rowRepairing(row)).toBeFalse(); + expect(component.rowEffectivelyDestroyed(row)).toBeTrue(); + expect(component.canRepair(row)).toBeFalse(); + + const renderedRow = fixture.nativeElement.querySelector('.weapon-equipment-row') as HTMLElement; + expect(renderedRow.classList.contains('destroyed-entry')).toBeTrue(); + expect(renderedRow.classList.contains('repairing-entry')).toBeFalse(); + expect(renderedRow.classList.contains('disabled-entry')).toBeFalse(); + const repairButton = Array.from(renderedRow.querySelectorAll('button')) + .find(button => button.textContent?.trim() === 'REPAIR'); + expect(repairButton).toBeUndefined(); }); it('uses real alternative modes and treats label-only modes as modifiers', () => { @@ -712,11 +783,36 @@ describe('WeaponsEquipmentPanelComponent', () => { const rows = component.groups().flatMap(group => group.rows); expect(rows.map(row => row.id)).toEqual(['LRM 20@RT#0', 'ISArtemisIV@RT#5']); - expect(rows[0].modifiers).toEqual([{ name: 'ISArtemisIV', destroyed: true }]); + expect(rows[0].modifiers).toEqual([{ name: 'ISArtemisIV', status: 'destroyed' }]); expect(rows[1].category).toBe('equipment'); expect(rows[1].display.name).toBe('ISArtemisIV'); }); + it('presents a disabled linked enhancement as disabled rather than destroyed', () => { + const artemis = entry({ + id: 'ISArtemisIV@RT#5', + equipment: misc('ISArtemisIV', ['F_WEAPON_ENHANCEMENT']), + states: new Map([[ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE]]), + el: svgEntry('w/Artemis IV') + }); + const lrm = entry({ + id: 'LRM 20@RT#0', + equipment: weapon('LRM 20', 'MML', 20), + linkedWith: [artemis], + el: svgEntry('LRM 20') + }); + artemis.parent = lrm; + + const { component, fixture } = createComponent([lrm, artemis]); + const modifier = fixture.nativeElement.querySelector('.modifier') as HTMLElement; + + expect(component.groups().find(group => group.id === 'ranged')!.rows[0].modifiers) + .toEqual([{ name: 'ISArtemisIV', status: 'disabled' }]); + expect(modifier.textContent?.trim()).toBe('ISArtemisIV Disabled'); + expect(modifier.classList.contains('disabled')).toBeTrue(); + expect(modifier.classList.contains('destroyed')).toBeFalse(); + }); + it('resolves a TW Apollo-linked MRM +1 modifier to +0', () => { const apollo = entry({ id: 'Apollo@RT#1', @@ -805,7 +901,7 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(apollo.isDestroying()).toBeTrue(); expect(unit.setInventoryEntry).toHaveBeenCalledWith(apollo); expect(component.groups().find(group => group.id === 'ranged')!.rows[0].display.hit).toBe('+0'); - expect(component.groups().find(group => group.id === 'ranged')!.rows[0].modifiers[0].destroyed).toBeFalse(); + expect(component.groups().find(group => group.id === 'ranged')!.rows[0].modifiers[0].status).toBe('available'); expect(equipmentRow.classList.contains('destroying-entry')).toBeTrue(); expect(toastService.showToast).toHaveBeenCalledWith('Critical Hit on Apollo', 'error'); @@ -814,7 +910,7 @@ describe('WeaponsEquipmentPanelComponent', () => { fixture.detectChanges(); expect(component.groups().find(group => group.id === 'ranged')!.rows[0].display.hit).toBe('+1'); - expect(component.groups().find(group => group.id === 'ranged')!.rows[0].modifiers[0].destroyed).toBeTrue(); + expect(component.groups().find(group => group.id === 'ranged')!.rows[0].modifiers[0].status).toBe('destroyed'); }); it('highlights the lost TW Apollo modifier when the linked Apollo is damaged', () => { @@ -830,11 +926,11 @@ describe('WeaponsEquipmentPanelComponent', () => { el: svgEntry('MRM 10RT41/Msl [C,M]3815') }); apollo.parent = mrm; - const entryStates = new Map([ - [apollo, createTestEquipmentState('destroyed', [])] + const equipmentStatuses = new Map([ + [apollo, 'destroyed'] ]); - const { component, fixture } = createComponent([mrm, apollo], {}, [], entryStates, { gameRules: TW_GAME_RULES }); + const { component, fixture } = createComponent([mrm, apollo], {}, [], equipmentStatuses, { gameRules: TW_GAME_RULES }); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; const targetState = component.targetState(row); const hitCell = fixture.nativeElement.querySelector('.hit-cell') as HTMLElement; @@ -857,14 +953,11 @@ describe('WeaponsEquipmentPanelComponent', () => { equipment: weapon('ER Medium Laser'), el: svgEntry('ER Medium Laser51015') }); - const entryStates = new Map([[laser, createTestEquipmentState( - 'available', - [ + const equipmentToHitModifiers = new Map([[laser, [ { label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' }, { label: 'Targeting Computer', modifier: -1 } - ] - )]]); - const { component, fixture } = createComponent([laser], {}, [], entryStates); + ]]]); + const { component, fixture } = createComponent([laser], {}, [], new Map(), { equipmentToHitModifiers }); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; const targetState = component.targetState(row); const hitCell = fixture.nativeElement.querySelector('.hit-cell') as HTMLElement; @@ -888,11 +981,11 @@ describe('WeaponsEquipmentPanelComponent', () => { equipment: weapon('ER Medium Laser'), el: svgEntry('ER Medium Laser51015') }); - const entryStates = new Map([[laser, createTestEquipmentState( - 'available', + const equipmentToHitModifiers = new Map([[ + laser, [{ label: 'Targeting Computer Destroyed', modifier: 0, weakened: true }] - )]]); - const { component, fixture } = createComponent([laser], {}, [], entryStates); + ]]); + const { component, fixture } = createComponent([laser], {}, [], new Map(), { equipmentToHitModifiers }); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; const hitCell = fixture.nativeElement.querySelector('.hit-cell') as HTMLElement; @@ -908,16 +1001,13 @@ describe('WeaponsEquipmentPanelComponent', () => { equipment: weapon('Pulse Laser', 'NA', 0, [3, 6, 9, 12], -1), el: svgEntry('Pulse Laser369') }); - const entryStates = new Map([[laser, createTestEquipmentState( - 'available', - [ + const equipmentToHitModifiers = new Map([[laser, [ { label: 'Damaged Fire Control', modifier: 1, weakened: true }, { label: 'Targeting Computer', modifier: -1 }, { label: 'Heat - Fire Modifier', modifier: 0, weakened: true, kind: 'heat' }, { label: 'Pulse Module', modifier: -1 } - ] - )]]); - const { component, fixture } = createComponent([laser], {}, [], entryStates); + ]]]); + const { component, fixture } = createComponent([laser], {}, [], new Map(), { equipmentToHitModifiers }); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; const hitCell = fixture.nativeElement.querySelector('.hit-cell') as HTMLElement; @@ -981,7 +1071,7 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(turnState.addFiredHeat).not.toHaveBeenCalled(); unit.setInventoryControlEntrySelected(row.entry, false); - registry.onEndTurn(ppc, context); + registry.onEndTurn(ppc, context.commandContext.toastService); component.inventoryControl().markInventoryViewChanged(); row = component.groups().find(group => group.id === 'ranged')!.rows[0]; @@ -1100,7 +1190,7 @@ describe('WeaponsEquipmentPanelComponent', () => { const rows = component.groups().flatMap(group => group.rows); row = rows.find(candidate => candidate.entry === laser)!; expect(component.modeChoice(row)).toBeUndefined(); - expect(unit.isEquipmentUnavailable(module)).toBeFalse(); + expect(unit.isEquipmentOperational(module)).toBeTrue(); }); it('shows the full range hit modifiers for multi-range weapons', () => { @@ -1119,11 +1209,13 @@ describe('WeaponsEquipmentPanelComponent', () => { let row = component.groups().find(group => group.id === 'ranged')!.rows[0]; expect(row.display.hit).toBe('-3/-2/-1'); + expect(component.targetState(row).hitText).toBe('-3/-2/-1'); component.selectRange(row, 'medium'); fixture.detectChanges(); row = component.groups().find(group => group.id === 'ranged')!.rows[0]; expect(row.display.hit).toBe('-2'); + expect(component.targetState(row).hitText).toBe('-2'); }); it('persists mode and sort order but keeps selection transient', async () => { @@ -1655,10 +1747,10 @@ describe('WeaponsEquipmentPanelComponent', () => { const broken = entry({ id: 'broken', equipment: weapon('broken'), destroyed: true, el: svgEntry('Broken') }); const disabled = entry({ id: 'disabled', equipment: weapon('disabled'), el: svgEntry('Disabled') }); const punch = entry({ id: 'punch', intrinsicPhysicalAttack: true, el: svgEntry('Punch') }); - const entryStates = new Map([ - [disabled, createTestEquipmentState('disabled', [])] + const equipmentStatuses = new Map([ + [disabled, 'disabled'] ]); - const { component, fixture, unit } = createComponent([first, second, broken, disabled, punch], {}, [], entryStates); + const { component, fixture, unit } = createComponent([first, second, broken, disabled, punch], {}, [], equipmentStatuses); unit.createInventoryControlTarget(); unit.inventoryControl.markInventoryViewChanged(); fixture.detectChanges(); @@ -1701,8 +1793,12 @@ describe('WeaponsEquipmentPanelComponent', () => { [laser], {}, [], - new Map([[laser, createTestEquipmentState('available', [{ label: 'Hit Modifier', modifier: 1 }])]]), - { gunnerySkill: 4, moveMode: 'run' } + new Map(), + { + equipmentToHitModifiers: new Map([[laser, [{ label: 'Hit Modifier', modifier: 1 }]]]), + gunnerySkill: 4, + moveMode: 'run' + } ); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; unit.createInventoryControlTarget(); @@ -1947,10 +2043,14 @@ describe('WeaponsEquipmentPanelComponent', () => { it('shows heat fire modifiers as a separate target number term', () => { const laser = entry({ id: 'laser', equipment: weapon('laser', 'NA', 0, [3, 6, 9, 12]), el: svgEntry('Wrong SVG Name999999') }); - const { component, fixture, unit } = createComponent([laser], {}, [], new Map([[laser, createTestEquipmentState('available', [ + const { component, fixture, unit } = createComponent([laser], {}, [], new Map(), { + equipmentToHitModifiers: new Map([[laser, [ { label: 'Hit Modifier', modifier: 1 }, { label: 'Heat - Fire Modifier', modifier: 2, weakened: true, kind: 'heat' } - ])]]), { gunnerySkill: 4, moveMode: 'stationary' }); + ]]]), + gunnerySkill: 4, + moveMode: 'stationary' + }); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; unit.createInventoryControlTarget(); unit.updateInventoryControlTarget('A', { distance: 4, tnModifier: 1 }); @@ -1973,9 +2073,13 @@ describe('WeaponsEquipmentPanelComponent', () => { it('extracts Aero heat from its entry-state hit modifier', () => { const laser = entry({ id: 'laser', equipment: weapon('laser', 'NA', 0, [3, 6, 9, 12]), el: svgEntry('Laser') }); - const { component, unit } = createComponent([laser], {}, [], new Map([[laser, createTestEquipmentState('available', [ + const { component, unit } = createComponent([laser], {}, [], new Map(), { + equipmentToHitModifiers: new Map([[laser, [ { label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' } - ])]]), { gunnerySkill: 4, moveMode: 'stationary' }); + ]]]), + gunnerySkill: 4, + moveMode: 'stationary' + }); const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; unit.createInventoryControlTarget(); unit.updateInventoryControlTarget('A', { distance: 1 }); @@ -2079,10 +2183,10 @@ describe('WeaponsEquipmentPanelComponent', () => { const broken = entry({ id: 'broken', equipment: weapon('broken'), destroyed: true, el: svgEntry('Broken') }); const disabled = entry({ id: 'disabled', equipment: weapon('disabled'), el: svgEntry('Disabled') }); const punch = entry({ id: 'punch', intrinsicPhysicalAttack: true, el: svgEntry('Punch') }); - const entryStates = new Map([ - [disabled, createTestEquipmentState('disabled', [])] + const equipmentStatuses = new Map([ + [disabled, 'disabled'] ]); - const { component, fixture, unit } = createComponent([first, second, broken, disabled, punch], {}, [], entryStates); + const { component, fixture, unit } = createComponent([first, second, broken, disabled, punch], {}, [], equipmentStatuses); fixture.detectChanges(); const sections = Array.from(fixture.nativeElement.querySelectorAll('.weapon-equipment-section')) as HTMLElement[]; @@ -2368,7 +2472,7 @@ describe('WeaponsEquipmentPanelComponent', () => { [standardAmmo.internalName]: standardAmmo, [artemisAmmo.internalName]: artemisAmmo, }; - const { component } = createComponent([lrm, standardBin, artemisBin], equipmentMap, [], new Map(), { tracksHeat: false }); + const { component, unit } = createComponent([lrm, standardBin, artemisBin], equipmentMap, [], new Map(), { tracksHeat: false }); let row = component.groups().find(group => group.id === 'ranged')!.rows[0]; expect(component.ammoState(row).selectedOptionId).toBe(row.ammo.options[0].id); @@ -2396,6 +2500,39 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(artemisBin.consumed).toBe(0); expect(component.ammoState(row).selectedOptionId).toBe(row.ammo.options[0].id); expect(component.ammoState(row).text).toBe('LRM 15 Ammo (0/6)'); + expect(row.selectedAmmoOption?.ammo).toBe(standardAmmo); + const availabilitySpy = spyOn(unit, 'isEquipmentOperational') + .and.throwError('selected profile must not inspect source availability'); + expect(unit.getInventoryControlSelectedAmmo(lrm)).toBe(standardAmmo); + expect(availabilitySpy).not.toHaveBeenCalled(); + }); + + it('preserves labeled equipment modifiers when recomputing selected-range hit text', () => { + const laser = entry({ + id: 'range-modifier-laser', + equipment: weapon('Range Modifier Laser', 'NA', 0, [3, 6, 9, 12]), + el: svgEntry('Range Modifier Laser369') + }); + const modifierBreakdown = [ + { label: 'Targeting Computer', modifier: -1 }, + { label: 'Damaged Fire Control', modifier: 2, weakened: true }, + ]; + const { component, unit } = createComponent( + [laser], + {}, + [], + new Map(), + { equipmentToHitModifiers: new Map([[laser, modifierBreakdown]]) } + ); + const resolveToHit = spyOn(unit.gameRules, 'resolveToHit').and.callThrough(); + + unit.setInventoryControlEntryRange(laser, 'short'); + component.groups(); + + const selectedRangeRequest = resolveToHit.calls.allArgs() + .map(([request]) => request) + .find(request => request.subject === laser && request.range === 'short'); + expect(selectedRangeRequest?.stateModifiers).toEqual(modifierBreakdown); }); it('switches to another compatible ammo bin after the selected bin is depleted', async () => { diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts index f93f9f8ea..78b5d2cc9 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts @@ -20,7 +20,7 @@ import type { InventoryControlRuntimeRangeKey, InventoryControlRuntimeTarget, In import { TooltipDirective } from '../../directives/tooltip.directive'; import type { TooltipLine } from '../tooltip/tooltip.component'; import { formatInventoryTargetSignedModifier, inventoryTargetNumberState, inventoryTargetRangeSelection, type InventoryTargetNumberInput, type InventoryTargetRangeSelection } from '../../utils/inventory-target-number.util'; -import { separateHeatFireModifier, SKILL_BREAKDOWN_PRIORITY, type C3DegradationSource, type ToHitResolution } from '../../models/rules/game-rules'; +import { SKILL_BREAKDOWN_PRIORITY, type C3DegradationSource, type ToHitResolution } from '../../models/rules/game-rules'; import type { EquipmentDialogContext } from './equipment-dialog.model'; import { formatInventoryControlModeName, @@ -162,7 +162,6 @@ export class WeaponsEquipmentPanelComponent { readonly contextInput = input.required({ alias: 'context' }); readonly readOnlyInput = input(undefined, { alias: 'readOnly' }); private pendingDragPreviewSizing: DragPreviewSizing | null = null; - private readonly implicitlySelectedAmmo = new Map(); readonly unit = computed(() => this.unitInput()); readonly usesAerospaceWeaponValues = computed(() => this.unit().getUnit().type === 'Aero'); readonly showsGroundExtremeRange = computed(() => @@ -184,7 +183,7 @@ export class WeaponsEquipmentPanelComponent { this.inventoryControl().inventoryViewVersion(); return getInventoryControlGroups( this.unit(), - this.context().dataService.getEquipmentRegistry(), + this.context().queryContext.equipmentCatalog, this.unit().getInventoryControlRules() ); }); @@ -436,8 +435,7 @@ export class WeaponsEquipmentPanelComponent { const rules = this.unit().getInventoryControlRules(); return this.unit().gameRules.resolveToHit({ subject: row.entry, - stateModifier: row.additionalHitModifier, - stateModifierBreakdown: row.hitModifierBreakdown ?? [], + stateModifiers: row.hitModifierBreakdown, range, adjustments: rules.resolveToHitAdjustments?.(row.entry, selectedAmmo) }); @@ -447,13 +445,14 @@ export class WeaponsEquipmentPanelComponent { row: InventoryControlRow, resolution: ToHitResolution, hasTarget: boolean, + hasSelectedRange: boolean, attackModifierBreakdown: readonly UnitModifierBreakdownEntry[] ): string { const attackModifier = attackModifierBreakdown.reduce((total, entry) => total + entry.modifier, 0); if (!hasTarget && resolution.value === null) { return row.display.hit; } - if (!hasTarget && resolution.profile.length > 1) { + if (!hasTarget && !hasSelectedRange && resolution.profile.length > 1) { return resolution.profile .map(value => formatInventoryTargetSignedModifier(value + attackModifier)) .join('/'); @@ -549,7 +548,6 @@ export class WeaponsEquipmentPanelComponent { this.inventoryControl().inventoryViewVersion(); const missingMovementModifier = this.unit().turnState().missingAttackMovementModifier(); const selectedAmmo = this.resolvedSelectedAmmoOption(row)?.ammo ?? null; - const { hitModifier, hitModifierBreakdown, heatFireModifier } = separateHeatFireModifier(hitResolution); return { entry: row.entry, category: row.category, @@ -562,9 +560,7 @@ export class WeaponsEquipmentPanelComponent { pilotingSkill: this.unit().rules.getBasePilotingSkill(), missingMovementModifier, attackModifierBreakdown: this.unit().turnState().getAttackModifierBreakdown(), - hitModifier, - hitModifierBreakdown, - heatFireModifier, + hitResolution, c3DegradationSource, gameRules: this.unit().gameRules }; @@ -605,7 +601,13 @@ export class WeaponsEquipmentPanelComponent { const weaponRuleRange = weaponRuleRangeSelection?.range ?? this.unit().getInventoryControlEntryRange(row.id) ?? null; const hitResolution = this.resolveHitForRange(row, weaponRuleRange); const attackModifierBreakdown = this.unit().turnState().getAttackModifierBreakdown(); - const hitText = this.hitTextForResolution(row, hitResolution, !!target, attackModifierBreakdown); + const hitText = this.hitTextForResolution( + row, + hitResolution, + !!target, + weaponRuleRange !== null, + attackModifierBreakdown, + ); const input = this.targetNumberInput(row, calculationTarget, hitResolution, c3Resolution.degradationSource); const targetNumber = inventoryTargetNumberState(input, rangeSelection); const breakdown = targetNumber.breakdown === null ? null : { total: targetNumber.breakdown.total, lines: targetNumber.breakdown.lines }; @@ -626,7 +628,7 @@ export class WeaponsEquipmentPanelComponent { selectedRange: inventoryControlDamageRange(weaponRuleRange), selectedAmmo, ammoProfile: selectedAmmoProfile, - equipmentCatalog: this.context().dataService.getEquipmentRegistry(), + equipmentCatalog: this.context().queryContext.equipmentCatalog, }, this.unit().getInventoryControlRules() ) ?? row.display.damage, @@ -660,7 +662,7 @@ export class WeaponsEquipmentPanelComponent { ammoState(row: InventoryControlRow): AmmoRowState { const hasUsableAmmo = this.hasUsableAmmoOption(row); const hasAmmo = row.tracksAmmo && hasUsableAmmo; - const selectedOption = this.resolvedSelectedAmmoOption(row, hasUsableAmmo); + const selectedOption = this.resolvedSelectedAmmoOption(row); const selectedOptionId = selectedOption?.id ?? ''; const text = this.ammoStateText(row, hasAmmo, selectedOption); const depleted = row.tracksAmmo @@ -680,14 +682,13 @@ export class WeaponsEquipmentPanelComponent { }; } - private resolvedSelectedAmmoOption(row: InventoryControlRow, _hasUsableAmmo = this.hasUsableAmmoOption(row)): InventoryControlAmmoOption | undefined { - const persistedOptionId = this.unit().getInventoryControlEntryAmmoOption(row.id) || undefined; - const selectedOption = resolveInventoryControlSelectedAmmoOption( + private resolvedSelectedAmmoOption(row: InventoryControlRow): InventoryControlAmmoOption | undefined { + const selection = this.unit().getInventoryControlEntryAmmoSelection(row.id); + return resolveInventoryControlSelectedAmmoOption( row.ammo.options, - persistedOptionId ?? this.implicitlySelectedAmmo.get(row.id) + selection?.selectedProfileId, + selection?.preferredSourceOptionId ); - if (!persistedOptionId && selectedOption) this.implicitlySelectedAmmo.set(row.id, selectedOption.id); - return selectedOption; } private ammoStateText(row: InventoryControlRow, hasAmmo: boolean, selectedOption: InventoryControlAmmoOption | undefined): string { @@ -707,8 +708,9 @@ export class WeaponsEquipmentPanelComponent { } selectAmmoOption(row: InventoryControlRow, value: string): void { - this.implicitlySelectedAmmo.delete(row.id); - this.unit().setInventoryControlEntryAmmoOption(row.id, value); + const option = row.ammo.options.find(candidate => candidate.id === value); + if (!option?.ammo) return; + this.persistResolvedAmmoSelection(row, option); } private canAdjustResolvedAmmo(row: InventoryControlRow, option: InventoryControlAmmoOption | undefined, delta: number, hasUsableAmmo: boolean): boolean { @@ -726,8 +728,9 @@ export class WeaponsEquipmentPanelComponent { if (delta === 0) return; const option = state.selectedOption; if (!option) return; - const changed = changeAmmoEntriesRemaining(this.getAmmoEntriesForOption(row, option.id), -delta, this.context()); + const changed = changeAmmoEntriesRemaining(this.getAmmoEntriesForOption(row, option.id), -delta, this.context().commandContext); if (changed) { + this.persistResolvedAmmoSelection(row, option); this.inventoryControl().markInventoryViewChanged(); } } @@ -738,7 +741,7 @@ export class WeaponsEquipmentPanelComponent { if (selectedRows.length === 0) return; const unavailableRow = selectedRows.find(row => row.disabled || row.destroyed); if (unavailableRow) { - await this.context().dialogsService.showError(`${unavailableRow.display.name} cannot be fired.`, 'Weapon Unavailable'); + await this.context().commandContext.dialogsService.showError(`${unavailableRow.display.name} cannot be fired.`, 'Weapon Unavailable'); return; } @@ -747,7 +750,7 @@ export class WeaponsEquipmentPanelComponent { if (!row.tracksAmmo) continue; const option = this.selectedAmmo(row); if (!option || option.destroyed || option.remaining <= 0) { - await this.context().dialogsService.showError(`${row.display.name} has no available ammo.`, 'No Ammo'); + await this.context().commandContext.dialogsService.showError(`${row.display.name} has no available ammo.`, 'No Ammo'); return; } const requestKey = option.id; @@ -763,7 +766,7 @@ export class WeaponsEquipmentPanelComponent { const remaining = this.getAmmoEntriesForOption(request.row, request.option.id) .reduce((total, entry) => total + getAmmoEntryRemaining(entry), 0); if (remaining < request.count) { - await this.context().dialogsService.showError(`${request.option.label} does not have enough ammo for the selected weapons.`, 'Not Enough Ammo'); + await this.context().commandContext.dialogsService.showError(`${request.option.label} does not have enough ammo for the selected weapons.`, 'Not Enough Ammo'); return; } } @@ -772,6 +775,7 @@ export class WeaponsEquipmentPanelComponent { const hasManualHeatTarget = this.unit().getHeat().next !== undefined; for (const request of requests.values()) { + this.persistResolvedAmmoSelection(request.row, request.option); this.consumeAmmoFromOption(request.row, request.option.id, request.count); } @@ -785,7 +789,7 @@ export class WeaponsEquipmentPanelComponent { this.inventoryControl().markInventoryViewChanged(); const ammoSummary = Array.from(requests.values()) .map(request => this.consumedAmmoSummaryItem(request)); - await this.context().dialogsService.showNoticeHtml( + await this.context().commandContext.dialogsService.showNoticeHtml( this.consumptionSummaryHtml(ammoSummary, heatProjection), 'Weapons Fired' ); @@ -795,8 +799,18 @@ export class WeaponsEquipmentPanelComponent { return this.ammoState(row).selectedOption; } + private persistResolvedAmmoSelection(row: InventoryControlRow, option: InventoryControlAmmoOption): void { + const selection = this.unit().getInventoryControlEntryAmmoSelection(row.id); + if (selection?.selectedProfileId === option.profileId + && selection.preferredSourceOptionId === option.id) return; + this.unit().setInventoryControlEntryAmmoSelection(row.id, { + selectedProfileId: option.profileId, + preferredSourceOptionId: option.id, + }); + } + private getAmmoEntriesForOption(row: InventoryControlRow, optionId: string) { - return getAmmoControlEntriesForWeapon(row.entry, this.context()) + return getAmmoControlEntriesForWeapon(row.entry, this.context().queryContext.equipmentCatalog) .filter(entry => entry.id === optionId || `${entry.currentAmmo.internalName}:${entry.locationLabel}` === optionId); } @@ -821,7 +835,7 @@ export class WeaponsEquipmentPanelComponent { private async runSelectedFireHooks(selectedRows: InventoryControlRow[]): Promise { for (const row of selectedRows) { - await this.context().registry.afterInventoryControlFire(row.entry, this.context()); + await this.context().registry.afterInventoryControlFire(row.entry); } } @@ -1041,7 +1055,7 @@ export class WeaponsEquipmentPanelComponent { async handleChoice(row: InventoryControlRow, choice: HandlerChoice): Promise { if (this.readOnly() || choice.disabled) return; - await this.context().registry.handleSelection(row.entry, choice, this.context()); + await this.context().registry.handleSelection(row.entry, choice, this.context().commandContext); this.inventoryControl().markInventoryViewChanged(); const updatedRow = this.groups().flatMap(group => group.rows).find(candidate => candidate.id === row.id); if (updatedRow && (updatedRow.disabled || updatedRow.destroyed) && this.isSelected(updatedRow)) { @@ -1051,7 +1065,7 @@ export class WeaponsEquipmentPanelComponent { private getHandlerChoices(row: InventoryControlRow): HandlerChoice[] { if (this.rowEffectivelyDestroyed(row)) return []; - return this.context().registry.getChoices(row.entry, this.context()); + return this.context().registry.getChoices(row.entry, this.context().queryContext); } private isModeChoice(choice: HandlerChoice): boolean { @@ -1060,43 +1074,55 @@ export class WeaponsEquipmentPanelComponent { } canMarkDestroyed(row: InventoryControlRow): boolean { - return !this.readOnly() && this.unit().hasDirectInventory() && !this.rowEffectivelyDestroyed(row); + return !this.readOnly() + && this.unit().hasDirectInventory() + && this.unit().canEditEquipmentState(row.entry, 'apply-damage'); } markDestroyed(row: InventoryControlRow): void { if (!this.canMarkDestroyed(row)) return; - if (row.entry.setPendingDestroyed(true)) { - row.entry.owner.setInventoryEntry(row.entry); - } - this.context().toastService.showToast(`Critical Hit on ${row.display.name}`, 'error'); + if (!this.unit().applyEquipmentDamage(row.entry)) return; + this.context().commandContext.toastService.showToast(`Critical Hit on ${row.display.name}`, 'error'); } canRepair(row: InventoryControlRow): boolean { - return !this.readOnly() && this.unit().hasDirectInventory() && this.rowEffectivelyDestroyed(row); + return !this.readOnly() + && this.unit().hasDirectInventory() + && this.rowEffectivelyDestroyed(row) + && this.unit().canEditEquipmentState(row.entry, 'repair'); } rowEffectivelyDestroyed(row: InventoryControlRow): boolean { - return row.entry.resolvedDestroyed(row.destroyed); + const state = this.rowPresentationState(row); + return state === 'destroying' || state === 'destroyed'; } rowDestroying(row: InventoryControlRow): boolean { - return row.entry.isDestroying(); + return this.rowPresentationState(row) === 'destroying'; } rowRepairing(row: InventoryControlRow): boolean { - return row.entry.isRepairing(); + return this.rowPresentationState(row) === 'repairing'; } rowCommittedDestroyed(row: InventoryControlRow): boolean { - return row.entry.resolvedCommittedDestroyed(row.destroyed); + return this.rowPresentationState(row) === 'destroyed'; + } + + rowPresentationState(row: InventoryControlRow): 'destroying' | 'repairing' | 'destroyed' | 'disabled' | null { + if (this.unit().getEquipmentInstallationLocationStatus(row.entry) === 'destroyed') return 'destroyed'; + if (row.entry.isRepairing()) return 'repairing'; + if (row.entry.isDestroying()) return 'destroying'; + if (row.destroyed) return 'destroyed'; + const status = this.unit().getEquipmentStatus(row.entry); + if (status === 'disabled') return 'disabled'; + return null; } repair(row: InventoryControlRow): void { if (!this.canRepair(row)) return; - if (row.entry.setPendingDestroyed(false)) { - row.entry.owner.setInventoryEntry(row.entry); - } - this.context().toastService.showToast(`Repaired ${row.display.name}`, 'success'); + if (!this.unit().repairEquipment(row.entry)) return; + this.context().commandContext.toastService.showToast(`Repaired ${row.display.name}`, 'success'); } } diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss index 518d413b6..afc96d92e 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.scss @@ -179,6 +179,24 @@ } } +@media (min-width: 480px) and (min-height: 480px) { + .top-right-controls { + gap: 10px; + + .overlay-round-button, + .turn-tracker-button, + .check-warning-button { + width: 46px; + height: 46px; + } + + .overlay-round-button svg { + width: 30px; + height: 30px; + } + } +} + @media print { :host { display: none !important; diff --git a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts index bbacb59e8..b9f32beef 100644 --- a/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts +++ b/src/app/components/page-viewer/overlay/page-interaction-overlay.component.ts @@ -22,7 +22,7 @@ import { DialogsService } from '../../../services/dialogs.service'; import { LoggerService } from '../../../services/logger.service'; import { OverlayManagerService } from '../../../services/overlay-manager.service'; import { DataService } from '../../../services/data.service'; -import { EquipmentInteractionRegistryService } from '../../../services/equipment-interaction-registry.service'; +import { createHandlerCommandContext, createHandlerQueryContext, EquipmentInteractionRegistryService } from '../../../services/equipment-interaction-registry.service'; import { ForceBuilderService } from '../../../services/force-builder.service'; import { ToastService } from '../../../services/toast.service'; import type { CBTForceUnit } from '../../../models/cbt-force-unit.model'; @@ -232,11 +232,11 @@ export class PageInteractionOverlayComponent { this.closeAllOverlays(); const unitList = this.pageViewerState.forceUnits().length > 0 ? this.pageViewerState.forceUnits() : [unit]; + const equipmentCatalog = this.dataService.getEquipmentRegistry(); const context: EquipmentDialogContext = { - toastService: this.toastService, - dialogsService: this.dialogsService, - dataService: this.dataService, - registry: this.equipmentRegistryService.getRegistry() + registry: this.equipmentRegistryService.getRegistry(), + queryContext: createHandlerQueryContext(equipmentCatalog), + commandContext: createHandlerCommandContext(equipmentCatalog, this.toastService, this.dialogsService), }; this.pageViewerState.beginInventoryDialog(); const ref = this.dialogsService.createDialog(EquipmentDialogComponent, { diff --git a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts index b33d2dd85..510c4e400 100644 --- a/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts +++ b/src/app/components/page-viewer/overlay/page-turn-summary-panel.component.ts @@ -12,7 +12,7 @@ import { HexSliderComponent } from '../../hex-slider/hex-slider.component'; import { TooltipDirective } from '../../../directives/tooltip.directive'; import type { TooltipLine } from '../../tooltip/tooltip.component'; import { calculateModifierTotal, type UnitModifierBreakdownEntry, type UnitModifierTotal } from '../../../models/rules/unit-type-rules'; -import { EquipmentInteractionRegistryService, type HandlerChoice, type HandlerContext } from '../../../services/equipment-interaction-registry.service'; +import { createHandlerCommandContext, createHandlerQueryContext, EquipmentInteractionRegistryService, type HandlerChoice, type HandlerCommandContext, type HandlerQueryContext } from '../../../services/equipment-interaction-registry.service'; import { ToastService } from '../../../services/toast.service'; import { DialogsService } from '../../../services/dialogs.service'; import { DataService } from '../../../services/data.service'; @@ -51,13 +51,16 @@ export class PageTurnSummaryPanelComponent { readonly endTurnForAllButtonVisible = input(false); readonly endTurnForAllClicked = output(); - private handlerContext(): HandlerContext { - return { - toastService: this.toastService, - dialogsService: this.dialogsService, - dataService: this.dataService, - choiceSurface: 'turn-summary', - }; + private queryContext(): HandlerQueryContext { + return createHandlerQueryContext(this.dataService.getEquipmentRegistry(), 'turn-summary'); + } + + private commandContext(): HandlerCommandContext { + return createHandlerCommandContext( + this.dataService.getEquipmentRegistry(), + this.toastService, + this.dialogsService + ); } endTurnForAll(event: MouseEvent): void { @@ -170,8 +173,8 @@ export class PageTurnSummaryPanelComponent { .filter(entry => entry.equipment?.flags?.has('F_MASC')) .map(entry => { const active = entry.equipment?.flags?.has('F_MASC') ? MascHandler.isActive(entry) : true; - const damaged = entry.resolvedDestroyed(); - const choices = this.equipmentRegistry.getChoices(entry, this.handlerContext()); + const damaged = entry.owner.isEquipmentResolvedDestroyed(entry); + const choices = this.equipmentRegistry.getChoices(entry, this.queryContext()); return { entry, label: entry.equipment?.name || entry.name, @@ -252,7 +255,7 @@ export class PageTurnSummaryPanelComponent { async handleEquipmentTrackChoice(row: EquipmentTrackControlRow, choice: HandlerChoice): Promise { if (choice.disabled) return; - await this.equipmentRegistry.handleSelection(row.entry, choice, this.handlerContext()); + await this.equipmentRegistry.handleSelection(row.entry, choice, this.commandContext()); this.unit()?.inventoryControl.markInventoryViewChanged(); } diff --git a/src/app/components/page-viewer/svg-interaction.service.spec.ts b/src/app/components/page-viewer/svg-interaction.service.spec.ts index 692cde86b..9937166e1 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -24,7 +24,6 @@ import { SvgInteractionService } from './svg-interaction.service'; import type { ZoomPanServiceInterface } from './zoom-pan.interface'; import { PageViewerStateService } from './internal/page-viewer-state.service'; import { CORE_2026_GAME_RULES } from '../../models/rules/game-rules'; -import { createTestEquipmentRules } from '../../testing/unit-test-helpers'; type SvgInteractionServicePrivate = { addSvgTapHandler( @@ -47,7 +46,7 @@ const NO_CONDITION_RULES = { conditionControls: [], crewStateControls: [], locationConditionControls: [], - ...createTestEquipmentRules(), + getEquipmentToHitModifiers: () => [], heatDissipation: () => null, getBaseGunnerySkill: () => 4, getBasePilotingSkill: () => 5, @@ -56,16 +55,18 @@ const NO_CONDITION_RULES = { function createSvgInteractionUnit(overrides: T): T & { getInventory: () => MountedEquipment[]; rules: typeof NO_CONDITION_RULES } { const unit = { getInventory: () => [], - isEquipmentUnavailable: () => false, + getEquipmentStatus: () => 'available', + isEquipmentOperational: () => true, + canPerformEquipmentAction: () => true, rules: NO_CONDITION_RULES, ...overrides, } as T & { getInventory: () => MountedEquipment[]; - isEquipmentUnavailable: (entry: MountedEquipment) => boolean; - isEquipmentActionUnavailable?: (entry: MountedEquipment) => boolean; + getEquipmentStatus: (entry: MountedEquipment) => 'available' | 'disabled' | 'destroyed'; + isEquipmentOperational: (entry: MountedEquipment) => boolean; + canPerformEquipmentAction: (entry: MountedEquipment) => boolean; rules: typeof NO_CONDITION_RULES; }; - unit.isEquipmentActionUnavailable ??= entry => unit.isEquipmentUnavailable(entry); return unit; } @@ -385,7 +386,6 @@ describe('SvgInteractionService', () => { entry.equipment?.flags.add('F_ENERGY'); entry.equipment?.flags.add('F_LASER'); entry.linkedWith = [module]; - module.owner = unit; unit.getInventory = () => [entry, module]; service.updateUnit(unit); service.setupInteractions(svg); @@ -455,13 +455,7 @@ describe('SvgInteractionService', () => { svg.innerHTML = 'Active Probe'; const critSlot = { id: 'CLActiveProbe@CT#0', name: 'CLActiveProbe', loc: 'CT', slot: 0 }; const equipment = new MiscEquipment({ id: 'CLActiveProbe', name: 'Active Probe', type: 'misc', flags: ['F_BAP'] }); - const entry = new MountedEquipment({ - owner: undefined as any, - id: 'CLActiveProbe@CT#0', - name: 'CLActiveProbe', - equipment, - critSlots: [critSlot] - }); + let entry!: MountedEquipment; const unit = createSvgInteractionUnit({ id: 'unit-a', getUnit: () => ({ type: 'Mek' }), @@ -469,10 +463,17 @@ describe('SvgInteractionService', () => { getCritSlots: () => [critSlot], getCritSlot: (loc: string, slot: number) => loc === 'CT' && slot === 0 ? critSlot : null, isInternalLocPhysicallyDestroyed: () => false, - isEquipmentUnavailable: () => false, + getEquipmentStatus: () => 'available' as const, + isEquipmentOperational: () => true, applyHitToCritSlot: jasmine.createSpy('applyHitToCritSlot') }); - entry.owner = unit as any; + entry = new MountedEquipment({ + owner: unit as any, + id: 'CLActiveProbe@CT#0', + name: 'CLActiveProbe', + equipment, + critSlots: [critSlot] + }); const handlerChoice = { label: 'Active Probe is OFF', value: 'enabled', @@ -487,9 +488,8 @@ describe('SvgInteractionService', () => { const pickerConfig = pickerFactory.createChoicePicker.calls.mostRecent().args[0]; expect(registryGetChoices).toHaveBeenCalledWith(entry, jasmine.objectContaining({ - toastService: jasmine.any(Object), - dialogsService: jasmine.any(Object), - dataService: jasmine.any(Object) + equipmentCatalog: jasmine.any(EquipmentRegistry), + choiceSurface: 'critical' })); expect(pickerConfig.values.map((choice: { label: string }) => choice.label)).toContain('Active Probe is OFF'); @@ -1572,16 +1572,7 @@ function createInventoryInteractionUnit(html = ` flags: weaponType === 'ATM' ? ['F_MISSILE', 'F_ATM'] : weaponType === 'MML' ? ['F_MISSILE', 'F_MML'] : [], weapon: { ammoType: weaponType === 'Laser' ? 'NA' : weaponType, rackSize: 6, ranges: [3, 6, 9, 12] } }); - const entry = new MountedEquipment({ - owner: undefined as any, - id: 'laser', - name: 'laser', - equipment, - states: new Map(), - el: entryEl, - destroyed: false, - linkedWith: null, - }); + let entry!: MountedEquipment; const unit = createSvgInteractionUnit({ id: 'unit-a', getInventory: () => [entry], @@ -1601,6 +1592,16 @@ function createInventoryInteractionUnit(html = ` }), setInventoryEntry: jasmine.createSpy('setInventoryEntry'), }); + entry = new MountedEquipment({ + owner: unit as any, + id: 'laser', + name: 'laser', + equipment, + states: new Map(), + el: entryEl, + destroyed: false, + linkedWith: null, + }); const runtime = new InventoryControlRuntimeState(() => unit.getInventory()); Object.assign(unit, { getInventoryControlTargets: () => runtime.getTargets(), @@ -1608,7 +1609,8 @@ function createInventoryInteractionUnit(html = ` getInventoryControlEntryTargetId: (entryId: string) => runtime.getEntryTargetId(entryId), isInventoryControlEntrySelected: (entryId: string) => runtime.isEntrySelected(entryId), getInventoryControlEntryRange: (entryId: string) => runtime.getEntryRange(entryId), - getInventoryControlEntryAmmoOption: () => undefined, + getInventoryControlEntryAmmoSelection: () => undefined, + getInventoryControlSelectedAmmo: () => null, getInventoryControlRules: () => ({}), gameRules: CORE_2026_GAME_RULES, allowsExtremeRangeAttacks: () => false, @@ -1626,6 +1628,5 @@ function createInventoryInteractionUnit(html = ` updateInventoryControlTarget: (targetId: string, patch: any) => runtime.updateTarget(targetId, patch), syncInventoryControlSelectionSvg: () => runtime.syncSelectionSvg() }); - entry.owner = unit as any; return { svg, entry, unit }; } diff --git a/src/app/components/page-viewer/svg-interaction.service.ts b/src/app/components/page-viewer/svg-interaction.service.ts index 4074acfa4..23e0196fc 100644 --- a/src/app/components/page-viewer/svg-interaction.service.ts +++ b/src/app/components/page-viewer/svg-interaction.service.ts @@ -20,7 +20,7 @@ import { ToastService } from '../../services/toast.service'; import { LayoutService } from '../../services/layout.service'; import { DataService } from '../../services/data.service'; import { AmmoEquipment } from '../../models/equipment.model'; -import { EquipmentInteractionRegistryService } from '../../services/equipment-interaction-registry.service'; +import { createHandlerCommandContext, createHandlerQueryContext, EquipmentInteractionRegistryService } from '../../services/equipment-interaction-registry.service'; import type { HandlerChoice } from '../../services/equipment-interaction-registry.service'; import { ForceBuilderService } from '../../services/force-builder.service'; import { OverlayManagerService } from '../../services/overlay-manager.service'; @@ -32,7 +32,7 @@ import { WeaponTargetChoiceMenuComponent } from '../../components/equipment-dial import { getInventoryControlGroups, getInventoryControlModeAmmoSummary, getInventoryControlModes, getSelectedInventoryControlMode, INVENTORY_CONTROL_MODE_STATE, resolveInventoryControlSelectedAmmoOption, selectInventoryControlEntry, setInventoryControlMode, syncSvgMode, type InventoryRangeKey } from '../../utils/inventory-control.util'; import type { InventoryControlRuntimeTarget, InventoryControlRuntimeTargetId } from '../../models/inventory-control-runtime-state.model'; import { inventoryTargetCategory, inventoryTargetNumberText, inventoryTargetRangeSelection } from '../../utils/inventory-target-number.util'; -import { CORE_2026_GAME_RULES, separateHeatFireModifier } from '../../models/rules/game-rules'; +import { CORE_2026_GAME_RULES } from '../../models/rules/game-rules'; import { PageViewerStateService } from './internal/page-viewer-state.service'; import { committedCriticalHitCount, isRepeatableMotiveHitId, motiveHitLevelFromId, MOTIVE_HIT_PIP_COUNT, pendingCriticalHitTimestamps } from '../../models/rules/vehicle-motive-hit.util'; import { UnitStateDropdownComponent, type UnitStateDropdownChoice } from './unit-state-dropdown.component'; @@ -975,12 +975,9 @@ export class SvgInteractionService { const slot = parseInt(svgEl.getAttribute('slot') as string); const originalTotalAmmo = parseInt(svgEl.getAttribute('totalAmmo') || '0'); const equipmentRegistry = this.equipmentRegistryService.getRegistry(); - const handlerContext = { - toastService: this.toastService, - dialogsService: this.dialogsService, - dataService: this.dataService, - choiceSurface: 'critical' as const, - }; + const equipmentCatalog = this.dataService.getEquipmentRegistry(); + const queryContext = createHandlerQueryContext(equipmentCatalog, 'critical'); + const commandContext = createHandlerCommandContext(equipmentCatalog, this.toastService, this.dialogsService); let labelText = svgEl.textContent || ''; if (svgEl.classList.contains('ammoSlot')) { // for ammo, we remove the number at the end, example "Ammo (SRM 2) 5" should become "Ammo (SRM 2)" @@ -1023,9 +1020,9 @@ export class SvgInteractionService { } const inventoryEntry = this.inventoryEntryForCritSlot(unit, critSlot); if (inventoryEntry) { - values.push(...equipmentRegistry.getChoices(inventoryEntry, handlerContext)); + values.push(...equipmentRegistry.getChoices(inventoryEntry, queryContext)); } - if (!unit.isEquipmentUnavailable(critSlot) && critSlot.eq instanceof AmmoEquipment) { + if (unit.isEquipmentOperational(critSlot) && critSlot.eq instanceof AmmoEquipment) { values.unshift({ label: '+1', value: '+1', keepOpen: true, disabled: ((critSlot.consumed ?? 0) == 0) }); values.unshift({ label: '-1', value: '-1', keepOpen: true, disabled: ((critSlot.consumed ?? 0) >= totalAmmo) }); values.push({ label: 'Set Ammo', value: 'Set Ammo' }); @@ -1052,9 +1049,9 @@ export class SvgInteractionService { if (!critSlot) return; const inventoryEntry = this.inventoryEntryForCritSlot(unit, critSlot); if (inventoryEntry && choice._handler) { - await equipmentRegistry.handleSelection(inventoryEntry, choice, handlerContext); + await equipmentRegistry.handleSelection(inventoryEntry, choice, commandContext); } else if (choice.value == '+1') { - if (unit.isEquipmentUnavailable(critSlot)) return; + if (!unit.isEquipmentOperational(critSlot)) return; if (critSlot.consumed === undefined) { return; } @@ -1063,7 +1060,7 @@ export class SvgInteractionService { unit.setCritSlot(critSlot); showAmmoToast(critSlot, 1); } else if (choice.value == '-1') { - if (unit.isEquipmentUnavailable(critSlot)) return; + if (!unit.isEquipmentOperational(critSlot)) return; if (critSlot.consumed === undefined) { critSlot.consumed = 0; } @@ -1076,14 +1073,10 @@ export class SvgInteractionService { unit.setCritSlot(critSlot); this.toastService.showToast(`Emptied ${labelText}`, 'info'); } else if (choice.value == 'Set Ammo') { - if (unit.isEquipmentUnavailable(critSlot)) return; - const entry = getAmmoControlEntryForCriticalSlot(unit, critSlot, this.dataService.getEquipmentRegistry()); + if (!unit.isEquipmentOperational(critSlot)) return; + const entry = getAmmoControlEntryForCriticalSlot(unit, critSlot, equipmentCatalog); if (!entry) return; - if (await setAmmoEntry(entry, { - toastService: this.toastService, - dialogsService: this.dialogsService, - dataService: this.dataService - })) { + if (await setAmmoEntry(entry, commandContext)) { totalAmmo = entry.totalAmmo; labelText = entry.currentAmmo.shortName; } @@ -1130,8 +1123,8 @@ export class SvgInteractionService { const unit = this.unit(); if (!unit) return; const rules = unit?.getInventoryControlRules?.() - ?? this.equipmentRegistryService.getRegistry().inventoryControlRules(this.equipmentDialogContext()); - syncSvgMode(entry, getSelectedInventoryControlMode(entry, this.dataService.getEquipmentRegistry(), rules)); + ?? this.equipmentRegistryService.getRegistry().inventoryControlRules(this.equipmentDialogContext().queryContext); + syncSvgMode(entry, getSelectedInventoryControlMode(entry, this.dataService.getEquipmentRegistry(), rules.matchesAmmo)); const selectEntry = (button: SVGElement) => { const unit = this.unit(); @@ -1260,9 +1253,9 @@ export class SvgInteractionService { private selectedInventoryControlMode(entry: MountedEquipment): string | null { const unit = this.unit(); const rules = unit?.getInventoryControlRules?.() - ?? this.equipmentRegistryService.getRegistry().inventoryControlRules(this.equipmentDialogContext()); + ?? this.equipmentRegistryService.getRegistry().inventoryControlRules(this.equipmentDialogContext().queryContext); return entry.states.get(INVENTORY_CONTROL_MODE_STATE) - ?? getSelectedInventoryControlMode(entry, this.dataService.getEquipmentRegistry(), rules); + ?? getSelectedInventoryControlMode(entry, this.dataService.getEquipmentRegistry(), rules.matchesAmmo); } private toggleRiscLaserPulseMode(module: MountedEquipment): void { @@ -1278,11 +1271,11 @@ export class SvgInteractionService { } private equipmentDialogContext(): EquipmentDialogContext { + const equipmentCatalog = this.dataService.getEquipmentRegistry(); return { - toastService: this.toastService, - dialogsService: this.dialogsService, - dataService: this.dataService, - registry: this.equipmentRegistryService.getRegistry() + registry: this.equipmentRegistryService.getRegistry(), + queryContext: createHandlerQueryContext(equipmentCatalog), + commandContext: createHandlerCommandContext(equipmentCatalog, this.toastService, this.dialogsService), }; } @@ -1332,11 +1325,13 @@ export class SvgInteractionService { const gameRules = unit.gameRules ?? CORE_2026_GAME_RULES; const rules = unit.getInventoryControlRules?.() - ?? this.equipmentRegistryService.getRegistry().inventoryControlRules(this.equipmentDialogContext()); + ?? this.equipmentRegistryService.getRegistry().inventoryControlRules(this.equipmentDialogContext().queryContext); const ammoSummary = getInventoryControlModeAmmoSummary(entry, this.dataService.getEquipmentRegistry(), rules); + const ammoSelection = unit.getInventoryControlEntryAmmoSelection?.(entry.id); const selectedAmmo = resolveInventoryControlSelectedAmmoOption( ammoSummary.options, - unit.getInventoryControlEntryAmmoOption?.(entry.id) + ammoSelection?.selectedProfileId, + ammoSelection?.preferredSourceOptionId, )?.ammo ?? null; const row = getInventoryControlGroups(unit, this.dataService.getEquipmentRegistry(), rules) .flatMap(group => group.rows) @@ -1354,15 +1349,13 @@ export class SvgInteractionService { selectedAmmo, target: target.c3Distance === undefined ? target : { ...target, c3Distance: undefined } }); - const toHit = unit.rules.getEquipmentToHit(entry); + const stateModifiers = unit.rules.getEquipmentToHitModifiers(entry); const hitResolution = gameRules.resolveToHit({ subject: entry, - stateModifier: toHit.modifier, - stateModifierBreakdown: toHit.modifiers, + stateModifiers, range: weaponRangeSelection?.range ?? null, adjustments: rules.resolveToHitAdjustments?.(entry, selectedAmmo) }); - const { hitModifier, heatFireModifier } = separateHeatFireModifier(hitResolution); const missingMovementModifier = unit.turnState().missingAttackMovementModifier(); return inventoryTargetNumberText({ entry, @@ -1376,8 +1369,7 @@ export class SvgInteractionService { pilotingSkill: unit.rules.getBasePilotingSkill(), missingMovementModifier, attackModifierBreakdown: unit.turnState().getAttackModifierBreakdown(), - hitModifier, - heatFireModifier, + hitResolution, c3DegradationSource: c3Resolution.degradationSource, gameRules }); @@ -2252,4 +2244,4 @@ export class SvgInteractionService { this.overlayManager.closeAllManagedOverlays(); this.unit.set(null); } -} \ No newline at end of file +} diff --git a/src/app/components/unit-block/unit-block.component.ts b/src/app/components/unit-block/unit-block.component.ts index 52c256df4..b7daa62e4 100644 --- a/src/app/components/unit-block/unit-block.component.ts +++ b/src/app/components/unit-block/unit-block.component.ts @@ -164,12 +164,12 @@ export class UnitBlockComponent { if (forceUnit instanceof CBTForceUnit) { const tagMounts = forceUnit.getMountedEquipmentByFlag('F_TAG'); if (tagMounts.length === 0) return undefined; - const tag = tagMounts.find(mount => !mount.isActionUnavailable()) ?? tagMounts[0]; + const tag = tagMounts.find(mount => mount.owner.canPerformEquipmentAction(mount, 'activate')) ?? tagMounts[0]; const names = [tag.name, tag.equipment?.name, tag.equipment?.shortName, tag.equipment?.sortingName] .filter((name): name is string => !!name); return { label: names.some(name => /\blight\b/i.test(name)) ? 'LTAG' : 'TAG', - unavailable: tagMounts.every(mount => mount.isActionUnavailable()), + unavailable: tagMounts.every(mount => !mount.owner.canPerformEquipmentAction(mount, 'activate')), }; } return undefined; @@ -185,10 +185,10 @@ export class UnitBlockComponent { if (forceUnit instanceof CBTForceUnit) { const ecms = forceUnit.getMountedEquipmentByFlag('F_ECM'); if (ecms.length === 0) return null; - const mount = ecms.find(candidate => !candidate.isActionUnavailable()) ?? ecms[0]; + const mount = ecms.find(candidate => candidate.owner.canPerformEquipmentAction(candidate, 'activate')) ?? ecms[0]; return { mode: mount.states.get('ecm_mode') as ECMMode || ECMMode.ECM, - unavailable: ecms.every(candidate => candidate.isActionUnavailable()), + unavailable: ecms.every(candidate => !candidate.owner.canPerformEquipmentAction(candidate, 'activate')), }; } return null; diff --git a/src/app/equipment-handlers/apollo.handler.spec.ts b/src/app/equipment-handlers/apollo.handler.spec.ts index e560b616e..44a8d6979 100644 --- a/src/app/equipment-handlers/apollo.handler.spec.ts +++ b/src/app/equipment-handlers/apollo.handler.spec.ts @@ -4,26 +4,54 @@ import { MountedEquipment } from '../models/mounted-equipment.model'; import { WeaponEquipment, type AmmoType, type Equipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import type { EquipmentStatus } from '../models/equipment-status.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; +import { createHandlerCommandContext, createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import type { DialogsService } from '../services/dialogs.service'; +import type { ToastService } from '../services/toast.service'; import { APOLLO_MODE_STATE, APOLLO_SATURATION_MODE, APOLLO_STANDARD_MODE, ApolloHandler } from './apollo.handler'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { EquipmentFlag } from '../models/equipment-flags.type'; -function owner(unavailableEntry?: MountedEquipment, gameRules: CBTGameRules = CORE_2026_GAME_RULES) { +const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); +const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast']), + jasmine.createSpyObj('DialogsService', ['createDialog']), +); + +function owner(gameRules: CBTGameRules = CORE_2026_GAME_RULES) { + const getEquipmentStatus = (candidate: MountedEquipment): EquipmentStatus => candidate.committedDestroyed() + ? 'destroyed' + : 'available'; return { gameRules, - rules: createTestEquipmentRules({ - getEquipmentStatus: (candidate: MountedEquipment) => ( - candidate === unavailableEntry || candidate.committedDestroyed() ? 'destroyed' : 'available' - ), - }), + getEquipmentStatus, + isEquipmentOperational: (candidate: MountedEquipment) => getEquipmentStatus(candidate) === 'available', setInventoryEntry: jasmine.createSpy('setInventoryEntry') } as never; } -function entry(flags: EquipmentFlag[] = [], destroyed = false): MountedEquipment { - return new MountedEquipment({ owner: owner(), id: flags.join('-') || 'entry', name: 'Entry', equipment: { flags: new Set(flags) } as Equipment, destroyed }); +function entry( + flags: EquipmentFlag[] = [], + options: { destroyed?: boolean; status?: EquipmentStatus; gameRules?: CBTGameRules } = {} +): MountedEquipment { + const getEquipmentStatus = (candidate: MountedEquipment): EquipmentStatus => candidate.committedDestroyed() + ? 'destroyed' + : options.status ?? 'available'; + return new MountedEquipment({ + owner: { + gameRules: options.gameRules ?? CORE_2026_GAME_RULES, + getEquipmentStatus, + isEquipmentOperational: (candidate: MountedEquipment) => getEquipmentStatus(candidate) === 'available', + setInventoryEntry: jasmine.createSpy('setInventoryEntry') + } as never, + id: flags.join('-') || 'entry', + name: 'Entry', + equipment: { flags: new Set(flags) } as Equipment, + destroyed: options.destroyed + }); } function weapon( @@ -32,7 +60,7 @@ function weapon( flags: EquipmentFlag[] = ammoType === 'MRM' ? ['F_MRM'] : [] ): MountedEquipment { return new MountedEquipment({ - owner: owner(undefined, gameRules), + owner: owner(gameRules), id: ammoType.toLowerCase(), name: ammoType, equipment: new WeaponEquipment({ id: ammoType, name: ammoType, type: 'weapon', flags, weapon: { ammoType } }) @@ -43,44 +71,49 @@ describe('ApolloHandler', () => { const handler = new ApolloHandler(); it('applies the TW Apollo bonus to an intact linked MRM', () => { - const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO']); - apollo.owner = owner(undefined, TW_GAME_RULES); + const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO'], { gameRules: TW_GAME_RULES }); - expect(handler.getToHitAdjustments(apollo, { parent: weapon('MRM', TW_GAME_RULES) })).toEqual([{ + expect(handler.getToHitAdjustments(apollo, { parent: weapon('MRM', TW_GAME_RULES) }, queryContext)).toEqual([{ kind: 'add', label: 'Entry', modifier: -1, weakened: false }]); }); - it('does not apply the TW Apollo bonus when Apollo is unavailable', () => { - const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO']); - apollo.owner = owner(apollo, TW_GAME_RULES); + it('presents a destroyed TW Apollo separately from its neutral modifier', () => { + const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO'], { destroyed: true, gameRules: TW_GAME_RULES }); - expect(handler.getToHitAdjustments(apollo, { parent: weapon('MRM', TW_GAME_RULES) })).toEqual([{ + expect(handler.getToHitAdjustments(apollo, { parent: weapon('MRM', TW_GAME_RULES) }, queryContext)).toEqual([{ kind: 'add', label: 'Entry Destroyed', modifier: 0, weakened: true }]); }); + it('presents a disabled TW Apollo separately from a destroyed one', () => { + const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO'], { status: 'disabled', gameRules: TW_GAME_RULES }); + + expect(handler.getToHitAdjustments(apollo, { parent: weapon('MRM', TW_GAME_RULES) }, queryContext)).toEqual([{ + kind: 'add', label: 'Entry Disabled', modifier: 0, weakened: true + }]); + }); + it('keeps the Core 2026 Apollo modifier neutral for MRMs', () => { expect(handler.getToHitAdjustments( entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO']), - { parent: weapon('MRM') } + { parent: weapon('MRM') }, + queryContext )).toEqual([]); }); it('does not apply the TW Apollo bonus to incompatible launchers', () => { - const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO']); - apollo.owner = owner(undefined, TW_GAME_RULES); + const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO'], { gameRules: TW_GAME_RULES }); - expect(handler.getToHitAdjustments(apollo, { parent: weapon('LRM', TW_GAME_RULES) })).toEqual([]); - expect(handler.getToHitAdjustments(apollo, { parent: weapon('MML', TW_GAME_RULES) })).toEqual([]); + expect(handler.getToHitAdjustments(apollo, { parent: weapon('LRM', TW_GAME_RULES) }, queryContext)).toEqual([]); + expect(handler.getToHitAdjustments(apollo, { parent: weapon('MML', TW_GAME_RULES) }, queryContext)).toEqual([]); }); it('identifies MRMs by F_MRM rather than their ammo type', () => { - const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO']); - apollo.owner = owner(undefined, TW_GAME_RULES); + const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO'], { gameRules: TW_GAME_RULES }); - expect(handler.getToHitAdjustments(apollo, { parent: weapon('MRM', TW_GAME_RULES, []) })).toEqual([]); - expect(handler.getToHitAdjustments(apollo, { parent: weapon('LRM', TW_GAME_RULES, ['F_MRM']) })).toEqual([{ + expect(handler.getToHitAdjustments(apollo, { parent: weapon('MRM', TW_GAME_RULES, []) }, queryContext)).toEqual([]); + expect(handler.getToHitAdjustments(apollo, { parent: weapon('LRM', TW_GAME_RULES, ['F_MRM']) }, queryContext)).toEqual([{ kind: 'add', label: 'Entry', modifier: -1, weakened: false }]); }); @@ -100,15 +133,37 @@ describe('ApolloHandler', () => { apollo, launcher, new Set(['C', 'M']), - {} as never + queryContext ); expect(Array.from(types ?? [])).toEqual(['C', 'M', 'AE']); }); - it('uses standard mode when the linked Apollo is unavailable', () => { + it('uses the canonical query context for pure Apollo projections without mutating inputs', () => { const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO']); - apollo.owner = owner(apollo); + apollo.owner.getEquipmentStatus = () => { throw new Error('owner status must not be queried'); }; + apollo.owner.isEquipmentOperational = () => { throw new Error('owner operational state must not be queried'); }; + const launcher = new MountedEquipment({ + owner: owner(), + id: 'mrm-context', + name: 'MRM 10', + equipment: new WeaponEquipment({ id: 'MRM10', name: 'MRM 10', type: 'weapon', flags: ['F_MRM'], weapon: { ammoType: 'MRM' } }), + linkedWith: [apollo], + states: new Map([[APOLLO_MODE_STATE, APOLLO_SATURATION_MODE]]) + }); + const context = { ...queryContext, getStatus: () => 'available' as EquipmentStatus }; + const baseTypes = new Set(['C', 'M'] as const); + const initialStates = new Map(launcher.states); + + const types = handler.applyLinkedWeaponTypes(apollo, launcher, baseTypes, context); + + expect(Array.from(types)).toEqual(['C', 'M', 'AE']); + expect(Array.from(baseTypes)).toEqual(['C', 'M']); + expect(launcher.states).toEqual(initialStates); + }); + + it('uses standard mode when the linked Apollo is disabled', () => { + const apollo = entry(['F_WEAPON_ENHANCEMENT', 'F_APOLLO'], { status: 'disabled' }); const launcher = new MountedEquipment({ owner: owner(), id: 'mrm', @@ -122,10 +177,10 @@ describe('ApolloHandler', () => { apollo, launcher, new Set(['C', 'M']), - {} as never + queryContext ); - expect(handler.getChoices(launcher, {} as never)?.[0].value).toBe(APOLLO_STANDARD_MODE); + expect(handler.getChoices(launcher, queryContext)?.[0].value).toBe(APOLLO_STANDARD_MODE); expect(Array.from(types ?? [])).toEqual(['C', 'M']); }); @@ -140,10 +195,10 @@ describe('ApolloHandler', () => { states: new Map([[INVENTORY_CONTROL_MODE_STATE, 'Extended Range']]) }); - handler.handleSelection(launcher, { value: APOLLO_SATURATION_MODE } as never, {} as never); + handler.handleSelection(launcher, { value: APOLLO_SATURATION_MODE } as never, commandContext); expect(launcher.states.get(APOLLO_MODE_STATE)).toBe(APOLLO_SATURATION_MODE); expect(launcher.states.get(INVENTORY_CONTROL_MODE_STATE)).toBe('Extended Range'); - expect(handler.getChoices(launcher, {} as never)?.[0].value).toBe(APOLLO_SATURATION_MODE); + expect(handler.getChoices(launcher, queryContext)?.[0].value).toBe(APOLLO_SATURATION_MODE); }); }); diff --git a/src/app/equipment-handlers/apollo.handler.ts b/src/app/equipment-handlers/apollo.handler.ts index 640da0442..ee298c037 100644 --- a/src/app/equipment-handlers/apollo.handler.ts +++ b/src/app/equipment-handlers/apollo.handler.ts @@ -6,7 +6,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; 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'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext, type ToHitAdjustmentContext } from '../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; export const APOLLO_STANDARD_MODE = 'Standard'; @@ -20,23 +20,23 @@ export class ApolloHandler extends EquipmentInteractionHandler { return isApollo(equipment) || isMrmWithApollo(equipment); } - getChoices(equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, context: HandlerQueryContext): PickerChoice[] { if (!equipment.owner.gameRules.supportsApolloSaturationMode || !isMrmWithApollo(equipment)) return []; const apollo = linkedApollo(equipment); return [{ label: 'Mode', - value: selectedApolloMode(equipment), + value: selectedApolloMode(equipment, context), displayType: 'dropdown', choices: [ { label: 'STD', value: APOLLO_STANDARD_MODE }, { label: 'SAT', value: APOLLO_SATURATION_MODE } ], - disabled: equipment.isUnavailable() || apollo?.isUnavailable() === true, + disabled: apollo != null && context.getStatus(apollo) !== 'available', keepOpen: true }]; } - handleSelection(equipment: MountedEquipment, choice: PickerChoice, _context: HandlerContext): boolean { + handleSelection(equipment: MountedEquipment, choice: PickerChoice, _context: HandlerCommandContext): boolean { if (isMrmWithApollo(equipment)) { if (equipment.setState(APOLLO_MODE_STATE, String(choice.value))) { equipment.owner.setInventoryEntry(equipment); @@ -45,14 +45,23 @@ export class ApolloHandler extends EquipmentInteractionHandler { return false; } - override getToHitAdjustments(equipment: MountedEquipment, context: ToHitAdjustmentContext): readonly ToHitAdjustment[] { - const parent = context.parent; + override getToHitAdjustments( + equipment: MountedEquipment, + adjustmentContext: ToHitAdjustmentContext, + context: HandlerQueryContext + ): readonly ToHitAdjustment[] { + const parent = adjustmentContext.parent; if (!parent || equipment.owner.gameRules.supportsApolloSaturationMode || !isApollo(equipment) || !isMrmWeapon(parent)) return []; - const weakened = equipment.isUnavailable(); + const status = context.getStatus(equipment); + const weakened = status !== 'available'; const label = equipment.equipment?.shortName ?? equipment.name; return [{ kind: 'add', - label: weakened ? `${label} Destroyed` : label, + label: status === 'destroyed' + ? `${label} Destroyed` + : status === 'disabled' + ? `${label} Disabled` + : label, modifier: weakened ? 0 : -1, weakened }]; @@ -62,13 +71,13 @@ export class ApolloHandler extends EquipmentInteractionHandler { equipment: MountedEquipment, parent: MountedEquipment, types: ReadonlySet, - _context: HandlerContext + context: HandlerQueryContext ): ReadonlySet { if (!equipment.owner.gameRules.supportsApolloSaturationMode || !isApollo(equipment) || !isMrmWithApollo(parent) - || equipment.isUnavailable() - || selectedApolloMode(parent) !== APOLLO_SATURATION_MODE) { + || context.getStatus(equipment) !== 'available' + || selectedApolloMode(parent, context) !== APOLLO_SATURATION_MODE) { return types; } return new Set([...types, 'AE']); @@ -92,11 +101,12 @@ export function linkedApollo(equipment: MountedEquipment): MountedEquipment | nu return equipment.linkedWith?.find(isApollo) ?? null; } -export function selectedApolloMode(equipment: MountedEquipment): string { - if (linkedApollo(equipment)?.isUnavailable()) return APOLLO_STANDARD_MODE; +export function selectedApolloMode(equipment: MountedEquipment, context: HandlerQueryContext): string { + const apollo = linkedApollo(equipment); + if (apollo && context.getStatus(apollo) !== 'available') return APOLLO_STANDARD_MODE; const mode = equipment.states.get(APOLLO_MODE_STATE) ?? equipment.states.get(INVENTORY_CONTROL_MODE_STATE); return mode === APOLLO_SATURATION_MODE ? APOLLO_SATURATION_MODE : APOLLO_STANDARD_MODE; -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/artemis-v.handler.spec.ts b/src/app/equipment-handlers/artemis-v.handler.spec.ts index 35b8583b6..fdc7f3832 100644 --- a/src/app/equipment-handlers/artemis-v.handler.spec.ts +++ b/src/app/equipment-handlers/artemis-v.handler.spec.ts @@ -4,29 +4,32 @@ import { MountedEquipment } from '../models/mounted-equipment.model'; import { Equipment, type AmmoEquipment } from '../models/equipment.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; +import type { EquipmentStatus } from '../models/equipment-status.model'; import { ArtemisVHandler } from './artemis-v.handler'; import { EquipmentFlag } from '../models/equipment-flags.type'; import { AmmoMunitionFlag } from '../models/ammo-munition-flags.type'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import { createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; -function owner(unavailableEntry?: MountedEquipment, jammed = false) { - return { - rules: createTestEquipmentRules({ - getEquipmentStatus: (candidate: MountedEquipment) => ( - candidate === unavailableEntry || candidate.committedDestroyed() ? 'destroyed' : 'available' - ), - }), - getCondition: (condition: string) => condition === 'jammed' && jammed - } as never; -} +const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); -function entry(flags: EquipmentFlag[] = [], destroyed = false): MountedEquipment { +function entry( + flags: EquipmentFlag[] = [], + options: { destroyed?: boolean; status?: EquipmentStatus; jammed?: boolean } = {} +): MountedEquipment { + const getEquipmentStatus = (candidate: MountedEquipment): EquipmentStatus => candidate.committedDestroyed() + ? 'destroyed' + : options.status ?? 'available'; return new MountedEquipment({ - owner: owner(), + owner: { + getEquipmentStatus, + isEquipmentOperational: (candidate: MountedEquipment) => getEquipmentStatus(candidate) === 'available', + getCondition: (condition: string) => condition === 'jammed' && options.jammed === true + } as never, id: flags.join('-') || 'entry', name: 'Entry', equipment: new Equipment({ id: 'entry', name: 'Entry', type: 'misc', flags }), - destroyed + destroyed: options.destroyed }); } @@ -41,34 +44,40 @@ describe('ArtemisVHandler', () => { const handler = new ArtemisVHandler(); it('applies the Artemis V bonus when linked to a launcher using Artemis V-capable ammo', () => { - expect(handler.getToHitAdjustments(entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']), { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) })).toEqual([{ + expect(handler.getToHitAdjustments(entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']), { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) }, queryContext)).toEqual([{ kind: 'add', label: 'Entry', modifier: -1, weakened: false }]); }); it('no Artemis V hit modifier bonus when selected ammo is not Artemis V-capable', () => { - expect(handler.getToHitAdjustments(entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']), { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: ammo(['M_ARTEMIS_CAPABLE']) })).toEqual([{ + expect(handler.getToHitAdjustments(entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']), { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: ammo(['M_ARTEMIS_CAPABLE']) }, queryContext)).toEqual([{ kind: 'add', label: 'Incompatible Ammo (Test Ammo)', modifier: 0, weakened: true }]); - expect(handler.getToHitAdjustments(entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']), { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: null })).toEqual([{ + expect(handler.getToHitAdjustments(entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']), { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: null }, queryContext)).toEqual([{ kind: 'add', label: 'Artemis V Ammo Not Selected', modifier: 0, weakened: true }]); }); - it('no Artemis V hit modifier bonus when the linked enhancement is unavailable', () => { - const artemis = entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']); - artemis.owner = owner(artemis); + it('presents a destroyed linked Artemis V separately from its neutral modifier', () => { + const artemis = entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V'], { destroyed: true }); - expect(handler.getToHitAdjustments(artemis, { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) })).toEqual([{ + expect(handler.getToHitAdjustments(artemis, { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) }, queryContext)).toEqual([{ kind: 'add', label: 'Entry Destroyed', modifier: 0, weakened: true }]); }); + it('presents a disabled linked Artemis V separately from a destroyed one', () => { + const artemis = entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V'], { status: 'disabled' }); + + expect(handler.getToHitAdjustments(artemis, { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) }, queryContext)).toEqual([{ + kind: 'add', label: 'Entry Disabled', modifier: 0, weakened: true + }]); + }); + it('does not apply the Artemis V bonus when the unit is jammed', () => { - const artemis = entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']); - artemis.owner = owner(undefined, true); + const artemis = entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V'], { jammed: true }); - expect(handler.getToHitAdjustments(artemis, { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) })).toEqual([{ + expect(handler.getToHitAdjustments(artemis, { parent: entry(['F_ARTEMIS_COMPATIBLE']), selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) }, queryContext)).toEqual([{ kind: 'add', label: 'Unit Jammed', modifier: 0, weakened: true }]); }); @@ -76,14 +85,16 @@ describe('ArtemisVHandler', () => { it('does not apply a modifier to a launcher that is not Artemis-compatible', () => { expect(handler.getToHitAdjustments( entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']), - { parent: entry(), selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) } + { parent: entry(), selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) }, + queryContext )).toEqual([]); }); it('does not apply a modifier when Artemis V is not linked to a launcher', () => { expect(handler.getToHitAdjustments( entry(['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']), - { selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) } + { selectedAmmo: ammo(['M_ARTEMIS_V_CAPABLE']) }, + queryContext )).toEqual([]); }); -}); \ No newline at end of file +}); diff --git a/src/app/equipment-handlers/artemis-v.handler.ts b/src/app/equipment-handlers/artemis-v.handler.ts index 80f9a733d..787349ffb 100644 --- a/src/app/equipment-handlers/artemis-v.handler.ts +++ b/src/app/equipment-handlers/artemis-v.handler.ts @@ -7,36 +7,43 @@ import { EquipmentFlag } from '../models/equipment-flags.type'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { ToHitAdjustment } from '../models/rules/game-rules'; import { isArtemisCompatibleWeapon } from '../models/entity/utils/equipment-link-rules'; -import { EquipmentInteractionHandler, type HandlerContext, type ToHitAdjustmentContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext, type ToHitAdjustmentContext } from '../services/equipment-interaction-registry.service'; export class ArtemisVHandler extends EquipmentInteractionHandler { readonly id = 'artemis-v-handler'; override readonly flags: EquipmentFlag[] = ['F_WEAPON_ENHANCEMENT', 'F_ARTEMIS_V']; - getChoices(_equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(_equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { return []; } - handleSelection(_equipment: MountedEquipment, _choice: PickerChoice, _context: HandlerContext): boolean { + handleSelection(_equipment: MountedEquipment, _choice: PickerChoice, _context: HandlerCommandContext): boolean { return false; } - override getToHitAdjustments(equipment: MountedEquipment, context: ToHitAdjustmentContext): readonly ToHitAdjustment[] { - const weapon = context.parent?.equipment; + override getToHitAdjustments( + equipment: MountedEquipment, + adjustmentContext: ToHitAdjustmentContext, + context: HandlerQueryContext + ): readonly ToHitAdjustment[] { + const weapon = adjustmentContext.parent?.equipment; if (!weapon || !isArtemisCompatibleWeapon(weapon)) return []; - const selectedAmmo = context.selectedAmmo; - const unavailable = equipment.isUnavailable(); + const selectedAmmo = adjustmentContext.selectedAmmo; + const status = context.getStatus(equipment); + const unavailable = status !== 'available'; const unitJammed = equipment.owner.getCondition('jammed'); const incompatibleAmmo = selectedAmmo !== undefined && !selectedAmmo?.hasMunitionType('M_ARTEMIS_V_CAPABLE'); const weakened = unavailable || unitJammed || incompatibleAmmo; const label = equipment.equipment?.shortName ?? equipment.name; - const unavailableLabel = unavailable + const unavailableLabel = status === 'destroyed' ? `${label} Destroyed` - : unitJammed - ? 'Unit Jammed' - : selectedAmmo - ? `Incompatible Ammo (${selectedAmmo.shortName})` - : 'Artemis V Ammo Not Selected'; + : status === 'disabled' + ? `${label} Disabled` + : unitJammed + ? 'Unit Jammed' + : selectedAmmo + ? `Incompatible Ammo (${selectedAmmo.shortName})` + : 'Artemis V Ammo Not Selected'; return [{ kind: 'add', label: weakened ? unavailableLabel : label, @@ -44,4 +51,4 @@ export class ArtemisVHandler extends EquipmentInteractionHandler { weakened }]; } -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/atm.handler.spec.ts b/src/app/equipment-handlers/atm.handler.spec.ts index 7967bcaa8..3242e3fe9 100644 --- a/src/app/equipment-handlers/atm.handler.spec.ts +++ b/src/app/equipment-handlers/atm.handler.spec.ts @@ -4,15 +4,14 @@ import { AmmoMunitionFlag } from '../models/ammo-munition-flags.type'; import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import { createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; import { AtmHandler } from './atm.handler'; function owner() { return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: createTestEquipmentRules(), } as never; } @@ -47,7 +46,7 @@ function ammo(id: string, munitionType: AmmoMunitionFlag): AmmoEquipment { describe('AtmHandler', () => { const handler = new AtmHandler(); - const context = {} as HandlerContext; + const context = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); it('does not duplicate SVG-owned mode picker choices', () => { expect(handler.getChoices(weapon(), context)).toEqual([]); @@ -74,4 +73,4 @@ describe('AtmHandler', () => { expect(handler.matchesInventoryAmmo(atm, ammo('he', 'M_HIGH_EXPLOSIVE'), null, context)).toBeTrue(); expect(handler.matchesInventoryAmmo(atm, ammo('std', 'M_STANDARD'), null, context)).toBeFalse(); }); -}); \ No newline at end of file +}); diff --git a/src/app/equipment-handlers/atm.handler.ts b/src/app/equipment-handlers/atm.handler.ts index 123cba2c3..fc17bcd76 100644 --- a/src/app/equipment-handlers/atm.handler.ts +++ b/src/app/equipment-handlers/atm.handler.ts @@ -6,7 +6,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { AmmoMunitionFlag } from '../models/ammo-munition-flags.type'; import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; import type { MountedEquipment } from '../models/mounted-equipment.model'; -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; const ATM_MUNITION_BY_MODE = new Map([ @@ -24,15 +24,15 @@ export class AtmHandler extends EquipmentInteractionHandler { && (equipment.equipment.ammoType === 'ATM' || equipment.equipment.ammoType === 'IATM'); } - getChoices(_equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(_equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { return []; } - handleSelection(_equipment: MountedEquipment, _choice: PickerChoice, _context: HandlerContext): boolean { + handleSelection(_equipment: MountedEquipment, _choice: PickerChoice, _context: HandlerCommandContext): boolean { return true; } - override matchesInventoryAmmo(equipment: MountedEquipment, ammo: AmmoEquipment, mode: string | null, _context: HandlerContext): boolean | null { + override matchesInventoryAmmo(equipment: MountedEquipment, ammo: AmmoEquipment, mode: string | null, _context: HandlerQueryContext): boolean | null { if (!(equipment.equipment instanceof WeaponEquipment) || (equipment.equipment.ammoType !== 'ATM' && equipment.equipment.ammoType !== 'IATM')) return null; if (ammo.ammoType !== equipment.equipment.ammoType) return false; if (equipment.equipment.rackSize > 0 && ammo.rackSize !== equipment.equipment.rackSize) return false; diff --git a/src/app/equipment-handlers/base/cycle-mode.handler.ts b/src/app/equipment-handlers/base/cycle-mode.handler.ts index 130221f03..1610dcc81 100644 --- a/src/app/equipment-handlers/base/cycle-mode.handler.ts +++ b/src/app/equipment-handlers/base/cycle-mode.handler.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { EquipmentInteractionHandler, type HandlerContext } from '../../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../../services/equipment-interaction-registry.service'; import type { MountedEquipment } from '../../models/mounted-equipment.model'; import type { PickerChoice, PickerValue } from '../../components/picker/picker.interface'; @@ -15,14 +15,14 @@ export abstract class CycleModeHandler extends EquipmentInteractionHandler { protected abstract getModes(equipment: MountedEquipment): Array; protected abstract getDefaultMode(): string; - getChoices(equipment: MountedEquipment, context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { const nextMode = this.getNextMode(equipment); // Return single choice representing the next mode - return [{...nextMode, disabled: equipment.isUnavailable() }]; + return [nextMode]; } - handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerContext): boolean { + handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): boolean { equipment.states?.set(this.stateKey, String(choice.value)); equipment.owner.setInventoryEntry(equipment); @@ -61,4 +61,4 @@ export abstract class CycleModeHandler extends EquipmentInteractionHandler { return modes[nextIndex]; } -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/base/multi-mode.handler.spec.ts b/src/app/equipment-handlers/base/multi-mode.handler.spec.ts new file mode 100644 index 000000000..c0d4cd2c4 --- /dev/null +++ b/src/app/equipment-handlers/base/multi-mode.handler.spec.ts @@ -0,0 +1,70 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { MiscEquipment } from '../../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../../models/equipment-lookup'; +import { MountedEquipment } from '../../models/mounted-equipment.model'; +import type { DialogsService } from '../../services/dialogs.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, +} from '../../services/equipment-interaction-registry.service'; +import type { ToastService } from '../../services/toast.service'; +import { MultiModeHandler } from './multi-mode.handler'; + +class TestMultiModeHandler extends MultiModeHandler { + readonly id = 'test-multi-mode'; + + protected getModes(_equipment: MountedEquipment): Array<{ value: string; label: string }> { + return [ + { label: 'Standard', value: 'standard' }, + { label: 'Alternate', value: 'alternate' }, + ]; + } + + protected getDefaultMode(): string { + return 'standard'; + } +} + +describe('MultiModeHandler', () => { + it('persists the selected mode value and round-trips it through the choices', () => { + const owner = { + setInventoryEntry: jasmine.createSpy('setInventoryEntry'), + } as never; + const equipment = new MountedEquipment({ + owner, + id: 'test-equipment', + name: 'Test Equipment', + equipment: new MiscEquipment({ + id: 'test-equipment', + name: 'Test Equipment', + type: 'misc', + }), + }); + const toastService = jasmine.createSpyObj('ToastService', ['showToast']); + const dialogsService = jasmine.createSpyObj('DialogsService', ['createDialog']); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + toastService, + dialogsService, + ); + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const handler = new TestMultiModeHandler(); + + expect(handler.handleSelection( + equipment, + { label: 'Alternate', value: 'alternate' }, + commandContext, + )).toBeTrue(); + + expect(equipment.states.get('state')).toBe('alternate'); + expect(equipment.states.get('state')).not.toBe('[object Object]'); + expect(equipment.owner.setInventoryEntry).toHaveBeenCalledWith(equipment); + expect(handler.getChoices(equipment, queryContext)).toEqual([ + jasmine.objectContaining({ value: 'standard', active: false }), + jasmine.objectContaining({ value: 'alternate', active: true }), + ]); + }); +}); diff --git a/src/app/equipment-handlers/base/multi-mode.handler.ts b/src/app/equipment-handlers/base/multi-mode.handler.ts index fe49da2ad..61993b1e8 100644 --- a/src/app/equipment-handlers/base/multi-mode.handler.ts +++ b/src/app/equipment-handlers/base/multi-mode.handler.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { EquipmentInteractionHandler, type HandlerContext } from '../../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../../services/equipment-interaction-registry.service'; import type { MountedEquipment } from '../../models/mounted-equipment.model'; -import type { PickerChoice, PickerValue } from '../../components/picker/picker.interface'; +import type { PickerChoice } from '../../components/picker/picker.interface'; /** * Base handler for equipment with multiple modes @@ -14,19 +14,18 @@ export abstract class MultiModeHandler extends EquipmentInteractionHandler { protected abstract getModes(equipment: MountedEquipment): Array<{ value: string; label: string; shortLabel?: string }>; protected abstract getDefaultMode(): string; - getChoices(equipment: MountedEquipment, context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { const currentState = equipment.states?.get(this.stateKey) || this.getDefaultMode(); return this.getModes(equipment).map(mode => ({ label: mode.label, shortLabel: mode.shortLabel, value: mode.value, - disabled: equipment.isUnavailable(), active: currentState === mode.value })); } - handleSelection(equipment: MountedEquipment, value: PickerChoice, context: HandlerContext): boolean { - equipment.states?.set(this.stateKey, String(value)); + handleSelection(equipment: MountedEquipment, value: PickerChoice, context: HandlerCommandContext): boolean { + equipment.states?.set(this.stateKey, String(value.value)); equipment.owner.setInventoryEntry(equipment); const mode = this.getModes(equipment).find(m => m.value === value.value); @@ -36,4 +35,4 @@ export abstract class MultiModeHandler extends EquipmentInteractionHandler { ); return true; } -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/base/toggle.handler.ts b/src/app/equipment-handlers/base/toggle.handler.ts index 9f159c01e..c719b8fb2 100644 --- a/src/app/equipment-handlers/base/toggle.handler.ts +++ b/src/app/equipment-handlers/base/toggle.handler.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { EquipmentInteractionHandler, type HandlerContext } from '../../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../../services/equipment-interaction-registry.service'; import type { MountedEquipment } from '../../models/mounted-equipment.model'; import type { PickerChoice, PickerValue } from '../../components/picker/picker.interface'; @@ -16,21 +16,20 @@ export abstract class ToggleHandler extends EquipmentInteractionHandler { protected readonly enabledToastVerb: string = 'enabled'; protected readonly disabledToastVerb: string = 'disabled'; - getChoices(equipment: MountedEquipment, context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { const currentState = equipment.states?.get(this.stateKey) || 'disabled'; const nextState = currentState === 'enabled' ? 'disabled' : 'enabled'; return [ { label: currentState === 'enabled' ? this.enabledLabel : this.disabledLabel, value: nextState, - disabled: equipment.isUnavailable(), active: currentState === 'enabled', displayType: 'toggle', }, ]; } - handleSelection(equipment: MountedEquipment, value: PickerChoice, context: HandlerContext): boolean { + handleSelection(equipment: MountedEquipment, value: PickerChoice, context: HandlerCommandContext): boolean { const newState = value.value === 'enabled' ? 'enabled' : 'disabled'; equipment.states?.set(this.stateKey, newState); equipment.owner.setInventoryEntry(equipment); @@ -40,4 +39,4 @@ export abstract class ToggleHandler extends EquipmentInteractionHandler { ); return true; } -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/bombast-laser.handler.spec.ts b/src/app/equipment-handlers/bombast-laser.handler.spec.ts index c9a8fc8e7..9102de87a 100644 --- a/src/app/equipment-handlers/bombast-laser.handler.spec.ts +++ b/src/app/equipment-handlers/bombast-laser.handler.spec.ts @@ -4,11 +4,18 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { MiscEquipment, WeaponEquipment, type WeaponDamage } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; 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 { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import { EquipmentInteractionRegistry, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import type { DialogsService } from '../services/dialogs.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionRegistry, + type HandlerCommandContext, +} from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { BOMBAST_LASER_CHARGED_COLOR, @@ -28,13 +35,11 @@ import { function owner(gameRules: CBTGameRules = CORE_2026_GAME_RULES) { return { gameRules, + readOnly: () => false, setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - isEquipmentActionUnavailable: jasmine.createSpy('isEquipmentActionUnavailable').and.returnValue(false), - rules: createTestEquipmentRules({ - getEquipmentStatus: (entry: MountedEquipment) => ( - entry.committedDestroyed() ? 'destroyed' : 'available' - ) - }) + canPerformEquipmentAction: (entry: MountedEquipment) => !entry.committedDestroyed(), + getEquipmentStatus: (entry: MountedEquipment) => entry.committedDestroyed() ? 'destroyed' : 'available', + isEquipmentOperational: (entry: MountedEquipment) => !entry.committedDestroyed(), } as never; } @@ -60,24 +65,37 @@ function bombastLaser( }); } -function context(): HandlerContext { +const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + +function commandContext(toastService = jasmine.createSpyObj('ToastService', ['showToast'])): HandlerCommandContext { + return createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + toastService, + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); +} + +function contexts() { + const toastService = jasmine.createSpyObj('ToastService', ['showToast']); return { - toastService: { showToast: jasmine.createSpy('showToast') } - } as unknown as HandlerContext; + query: queryContext, + command: commandContext(toastService), + toastService, + }; } const damageContext = {} as never; const baseDamage: WeaponDamage = { values: [12], maximum: 12 }; -function select(handler: BombastLaserHandler, entry: MountedEquipment, value: string, handlerContext = context()): void { - handler.handleSelection(entry, { value } as PickerChoice, handlerContext); +function select(handler: BombastLaserHandler, entry: MountedEquipment, value: string, context = commandContext()): void { + handler.handleSelection(entry, { value } as PickerChoice, context); } describe('BombastLaserHandler', () => { const handler = new BombastLaserHandler(); it('offers the three Core damage levels and a charge control', () => { - const choices = handler.getChoices(bombastLaser(), context()); + const choices = handler.getChoices(bombastLaser(), queryContext); expect(choices[0]).toEqual(jasmine.objectContaining({ label: 'Mode', @@ -130,9 +148,9 @@ describe('BombastLaserHandler', () => { for (const profile of profiles) { const entry = bombastLaser(CORE_2026_GAME_RULES, new Map([[INVENTORY_CONTROL_MODE_STATE, profile.mode]])); - expect(handler.applyInventoryControlDamageEffects(entry, baseDamage, damageContext, context())) + expect(handler.applyInventoryControlDamageEffects(entry, baseDamage, damageContext, queryContext)) .toEqual({ values: [profile.damage], maximum: profile.damage }); - expect(handler.applyInventoryControlHeatEffects(entry, { value: 12, weakened: false }, context())) + expect(handler.applyInventoryControlHeatEffects(entry, { value: 12, weakened: false }, queryContext)) .toEqual({ value: profile.heat, weakened: false }); } expect(baseDamage).toEqual({ values: [12], maximum: 12 }); @@ -144,7 +162,7 @@ describe('BombastLaserHandler', () => { ])); const rangedDamage: WeaponDamage = { values: [12, 10, 8], maximum: 12 }; - expect(handler.applyInventoryControlDamageEffects(entry, rangedDamage, damageContext, context())).toEqual({ + expect(handler.applyInventoryControlDamageEffects(entry, rangedDamage, damageContext, queryContext)).toEqual({ values: [8, 8, 8], maximum: 8 }); @@ -161,11 +179,11 @@ describe('BombastLaserHandler', () => { expect(handler.getToHitAdjustments(bombastLaser(CORE_2026_GAME_RULES, new Map([ [INVENTORY_CONTROL_MODE_STATE, BOMBAST_LASER_DAMAGE_8_MODE] - ])), {}, context())).toEqual([]); - expect(handler.getToHitAdjustments(damage12, {}, context())).toEqual([{ + ])), {}, queryContext)).toEqual([]); + expect(handler.getToHitAdjustments(damage12, {}, queryContext)).toEqual([{ kind: 'replace-base', value: 1, label: 'Bombast (Damage 12)' }]); - expect(handler.getToHitAdjustments(damage16, {}, context())).toEqual([{ + expect(handler.getToHitAdjustments(damage16, {}, queryContext)).toEqual([{ kind: 'replace-base', value: 2, label: 'Bombast (Damage 16)' }]); }); @@ -178,7 +196,7 @@ describe('BombastLaserHandler', () => { const entry = bombastLaser(CORE_2026_GAME_RULES, new Map([[INVENTORY_CONTROL_MODE_STATE, mode]])); const resolution = CORE_2026_GAME_RULES.resolveToHit({ subject: entry, - adjustments: handler.getToHitAdjustments(entry, {}, context()) + adjustments: handler.getToHitAdjustments(entry, {}, queryContext) }); expect(resolution.value).toBe(expected); @@ -197,25 +215,25 @@ describe('BombastLaserHandler', () => { [BOMBAST_LASER_CHARGE_STATE_KEY, BOMBAST_LASER_CHARGED_STATE] ])); - expect(handler.getToHitAdjustments(entry, {}, context())).toEqual([]); + expect(handler.getToHitAdjustments(entry, {}, queryContext)).toEqual([]); } }); it('charges for one turn, blocks firing, and becomes charged at end turn', () => { const entry = bombastLaser(); - const handlerContext = context(); + const testContexts = contexts(); - select(handler, entry, BOMBAST_LASER_CHARGING_STATE, handlerContext); + select(handler, entry, BOMBAST_LASER_CHARGING_STATE, testContexts.command); expect(entry.states.get(BOMBAST_LASER_CHARGE_STATE_KEY)).toBe(BOMBAST_LASER_CHARGING_STATE); - expect(handler.isInventoryControlSelectable(entry, handlerContext)).toBeFalse(); - expect(handlerContext.toastService.showToast).toHaveBeenCalledWith('Bombast Laser charging', 'info'); + expect(handler.isInventoryControlSelectable(entry, testContexts.query)).toBeFalse(); + expect(testContexts.toastService.showToast).toHaveBeenCalledWith('Bombast Laser charging', 'info'); - handler.onEndTurn(entry, handlerContext); + handler.onEndTurn(entry); expect(entry.states.get(BOMBAST_LASER_CHARGE_STATE_KEY)).toBe(BOMBAST_LASER_CHARGED_STATE); - expect(handler.isInventoryControlSelectable(entry, handlerContext)).toBeNull(); - expect(handler.getChoices(entry, handlerContext)[1]).toEqual(jasmine.objectContaining({ + expect(handler.isInventoryControlSelectable(entry, testContexts.query)).toBeNull(); + expect(handler.getChoices(entry, testContexts.query)[1]).toEqual(jasmine.objectContaining({ label: 'Laser Charged!', shortLabel: 'Charged!', value: 'discharged', @@ -233,35 +251,35 @@ describe('BombastLaserHandler', () => { ])); const types = new Set(['DE', 'V']); - expect(handler.applyInventoryControlWeaponTypes(entry, types, context())) + expect(handler.applyInventoryControlWeaponTypes(entry, types, queryContext)) .toEqual(new Set(['DE', 'V', 'X'])); expect(types).toEqual(new Set(['DE', 'V'])); - handler.afterInventoryControlFire(entry, context()); + handler.afterInventoryControlFire(entry); expect(entry.states.has(BOMBAST_LASER_CHARGE_STATE_KEY)).toBeFalse(); expect(entry.states.get(BOMBAST_LASER_FIRED_STATE_KEY)).toBe('1'); - expect(handler.applyInventoryControlWeaponTypes(entry, types, context())).toBe(types); + expect(handler.applyInventoryControlWeaponTypes(entry, types, queryContext)).toBe(types); expect(entry.owner.setInventoryEntry).toHaveBeenCalledWith(entry); }); it('rejects charging after firing until the turn ends', () => { const entry = bombastLaser(); - const handlerContext = context(); - handler.afterInventoryControlFire(entry, handlerContext); + const testContexts = contexts(); + handler.afterInventoryControlFire(entry); - select(handler, entry, BOMBAST_LASER_CHARGING_STATE, handlerContext); + select(handler, entry, BOMBAST_LASER_CHARGING_STATE, testContexts.command); expect(entry.states.has(BOMBAST_LASER_CHARGE_STATE_KEY)).toBeFalse(); - expect(handlerContext.toastService.showToast).toHaveBeenCalledWith( + expect(testContexts.toastService.showToast).toHaveBeenCalledWith( 'A fired Bombast Laser cannot charge this turn.', 'error' ); - handler.onEndTurn(entry, handlerContext); + handler.onEndTurn(entry); expect(entry.states.has(BOMBAST_LASER_FIRED_STATE_KEY)).toBeFalse(); - select(handler, entry, BOMBAST_LASER_CHARGING_STATE, handlerContext); + select(handler, entry, BOMBAST_LASER_CHARGING_STATE, testContexts.command); expect(entry.states.get(BOMBAST_LASER_CHARGE_STATE_KEY)).toBe(BOMBAST_LASER_CHARGING_STATE); }); @@ -269,23 +287,32 @@ describe('BombastLaserHandler', () => { const entry = bombastLaser(CORE_2026_GAME_RULES, new Map([ [BOMBAST_LASER_CHARGE_STATE_KEY, BOMBAST_LASER_CHARGED_STATE] ])); - const handlerContext = context(); + const testContexts = contexts(); - select(handler, entry, 'discharged', handlerContext); + select(handler, entry, 'discharged', testContexts.command); expect(entry.states.has(BOMBAST_LASER_CHARGE_STATE_KEY)).toBeFalse(); - expect(handlerContext.toastService.showToast).toHaveBeenCalledWith('Bombast Laser discharged', 'info'); + expect(testContexts.toastService.showToast).toHaveBeenCalledWith('Bombast Laser discharged', 'info'); }); - it('does not finish charging while unavailable', () => { + it('clears an unavailable laser charge instead of progressing it', () => { const entry = bombastLaser(CORE_2026_GAME_RULES, new Map([ [BOMBAST_LASER_CHARGE_STATE_KEY, BOMBAST_LASER_CHARGING_STATE] ]), true); + const registry = new EquipmentInteractionRegistry(); + registry.register(handler); - handler.onEndTurn(entry, context()); + expect(handler.isInventoryControlSelectable(entry, queryContext)).toBeNull(); - expect(entry.states.get(BOMBAST_LASER_CHARGE_STATE_KEY)).toBe(BOMBAST_LASER_CHARGING_STATE); - expect(handler.getChoices(entry, context()).every(choice => choice.disabled)).toBeTrue(); + handler.onEndTurn(entry); + + expect(entry.states.has(BOMBAST_LASER_CHARGE_STATE_KEY)).toBeFalse(); + expect(handler.getChoices(entry, queryContext)).toEqual([ + jasmine.objectContaining({ label: 'Mode', value: BOMBAST_LASER_DAMAGE_12_MODE }), + jasmine.objectContaining({ label: 'Charge Laser', active: false, disabled: false }), + ]); + expect(registry.getChoices(entry, queryContext).every(choice => choice.disabled)).toBeTrue(); + expect(entry.owner.setInventoryEntry).toHaveBeenCalledWith(entry); }); it('does not register any Bombast interaction under Total Warfare', () => { @@ -300,15 +327,15 @@ describe('BombastLaserHandler', () => { expect(handler.applicableTo(entry)).toBeFalse(); expect(registry.getHandlers(entry)).toEqual([]); - expect(registry.getChoices(entry, context())).toEqual([]); - expect(registry.applyInventoryControlDamageEffects(entry, baseDamage, damageContext, context())).toBe(baseDamage); - expect(registry.applyInventoryControlHeatEffects(entry, heat, context())).toBe(heat); - expect(registry.applyWeaponTypes(entry, types, context())).toBe(types); - expect(registry.getToHitAdjustments(entry, context())).toEqual([]); - expect(registry.isInventoryControlSelectable(entry, context())).toBeTrue(); - - registry.afterInventoryControlFire(entry, context()); - registry.onEndTurn(entry, context()); + expect(registry.getChoices(entry, queryContext)).toEqual([]); + expect(registry.applyInventoryControlDamageEffects(entry, baseDamage, damageContext, queryContext)).toBe(baseDamage); + expect(registry.applyInventoryControlHeatEffects(entry, heat, queryContext)).toBe(heat); + expect(registry.applyWeaponTypes(entry, types, queryContext)).toBe(types); + expect(registry.getToHitAdjustments(entry, queryContext)).toEqual([]); + expect(registry.isInventoryControlSelectable(entry, queryContext)).toBeTrue(); + + registry.afterInventoryControlFire(entry); + registry.onEndTurn(entry, jasmine.createSpyObj('ToastService', ['showToast'])); expect(entry.states.get(BOMBAST_LASER_CHARGE_STATE_KEY)).toBe(BOMBAST_LASER_CHARGED_STATE); expect(entry.owner.setInventoryEntry).not.toHaveBeenCalled(); }); diff --git a/src/app/equipment-handlers/bombast-laser.handler.ts b/src/app/equipment-handlers/bombast-laser.handler.ts index ac69d55b6..de4e5ade5 100644 --- a/src/app/equipment-handlers/bombast-laser.handler.ts +++ b/src/app/equipment-handlers/bombast-laser.handler.ts @@ -10,7 +10,8 @@ import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { ToHitAdjustment } from '../models/rules/game-rules'; import { EquipmentInteractionHandler, - type HandlerContext, + type HandlerCommandContext, + type HandlerQueryContext, type ToHitAdjustmentContext } from '../services/equipment-interaction-registry.service'; import type { InventoryControlDamageContext } from '../utils/inventory-control-damage.util'; @@ -58,7 +59,7 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { && equipment.equipment instanceof WeaponEquipment; } - override getChoices(equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + override getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { if (!supportsBombastLaserRules(equipment)) return []; const chargeState = bombastLaserChargeState(equipment); @@ -73,7 +74,6 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { { label: '12 DMG', value: BOMBAST_LASER_DAMAGE_12_MODE }, { label: '16 DMG', value: BOMBAST_LASER_DAMAGE_16_MODE } ], - disabled: equipment.isUnavailable(), keepOpen: true }, { @@ -85,7 +85,7 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { : chargeState === BOMBAST_LASER_CHARGING_STATE ? 'Charging' : 'Charge', value: active ? 'discharged' : BOMBAST_LASER_CHARGING_STATE, active, - disabled: equipment.isUnavailable() || equipment.states.has(BOMBAST_LASER_FIRED_STATE_KEY), + disabled: equipment.states.has(BOMBAST_LASER_FIRED_STATE_KEY), colors: active ? { selected: BOMBAST_LASER_CHARGED_COLOR, selectedText: BOMBAST_LASER_CHARGED_TEXT_COLOR } : undefined, @@ -94,7 +94,7 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { ]; } - override handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerContext): boolean { + override handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): boolean { if (!supportsBombastLaserRules(equipment)) return true; const mode = validBombastLaserMode(String(choice.value)); @@ -124,24 +124,28 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { return true; } - override afterInventoryControlFire(equipment: MountedEquipment, _context: HandlerContext): void { + override afterInventoryControlFire(equipment: MountedEquipment): void { if (!supportsBombastLaserRules(equipment)) return; const discharged = setBombastLaserChargeState(equipment, null); const markedFired = equipment.setState(BOMBAST_LASER_FIRED_STATE_KEY, '1'); if (discharged || markedFired) equipment.owner.setInventoryEntry(equipment); } - override onEndTurn(equipment: MountedEquipment, _context: HandlerContext): void { + override onEndTurn(equipment: MountedEquipment): void { if (!supportsBombastLaserRules(equipment)) return; let changed = equipment.deleteState(BOMBAST_LASER_FIRED_STATE_KEY); - if (!equipment.isUnavailable() && bombastLaserChargeState(equipment) === BOMBAST_LASER_CHARGING_STATE) { + const state = bombastLaserChargeState(equipment); + if (!equipment.owner.isEquipmentOperational(equipment) && state !== null) { + changed = setBombastLaserChargeState(equipment, null) || changed; + } else if (state === BOMBAST_LASER_CHARGING_STATE) { changed = setBombastLaserChargeState(equipment, BOMBAST_LASER_CHARGED_STATE) || changed; } if (changed) equipment.owner.setInventoryEntry(equipment); } - override isInventoryControlSelectable(equipment: MountedEquipment, _context: HandlerContext): boolean | null { + override isInventoryControlSelectable(equipment: MountedEquipment, context: HandlerQueryContext): boolean | null { return supportsBombastLaserRules(equipment) + && context.getStatus(equipment) === 'available' && bombastLaserChargeState(equipment) === BOMBAST_LASER_CHARGING_STATE ? false : null; @@ -151,7 +155,7 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { equipment: MountedEquipment, damage: WeaponDamage, _damageContext: InventoryControlDamageContext, - _context: HandlerContext + _context: HandlerQueryContext ): WeaponDamage { if (!supportsBombastLaserRules(equipment)) return damage; const selectedDamage = selectedBombastLaserProfile(equipment).damage; @@ -161,7 +165,7 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { override applyInventoryControlHeatEffects( equipment: MountedEquipment, effect: InventoryControlHeatEffect, - _context: HandlerContext + _context: HandlerQueryContext ): InventoryControlHeatEffect { return supportsBombastLaserRules(equipment) ? { ...effect, value: selectedBombastLaserProfile(equipment).heat } @@ -171,7 +175,7 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { override applyInventoryControlWeaponTypes( equipment: MountedEquipment, types: ReadonlySet, - _context: HandlerContext + _context: HandlerQueryContext ): ReadonlySet { return supportsBombastLaserRules(equipment) && bombastLaserChargeState(equipment) === BOMBAST_LASER_CHARGED_STATE @@ -182,7 +186,7 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { override getToHitAdjustments( equipment: MountedEquipment, _adjustmentContext: ToHitAdjustmentContext, - _context: HandlerContext + _context: HandlerQueryContext ): readonly ToHitAdjustment[] { if (!supportsBombastLaserRules(equipment) || bombastLaserChargeState(equipment) === BOMBAST_LASER_CHARGED_STATE) return []; @@ -229,4 +233,4 @@ function setBombastLaserChargeState(equipment: MountedEquipment, state: BombastL return state === null ? equipment.deleteState(BOMBAST_LASER_CHARGE_STATE_KEY) : equipment.setState(BOMBAST_LASER_CHARGE_STATE_KEY, state); -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/c3-emergency-master.handler.spec.ts b/src/app/equipment-handlers/c3-emergency-master.handler.spec.ts index 41b800c53..2bf98c223 100644 --- a/src/app/equipment-handlers/c3-emergency-master.handler.spec.ts +++ b/src/app/equipment-handlers/c3-emergency-master.handler.spec.ts @@ -11,9 +11,11 @@ import { isC3EmergencyMasterFried, type C3EmergencyMasterStatus, } from '../models/c3-emergency-master.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import type { DialogsService } from '../services/dialogs.service'; +import { createHandlerCommandContext, createHandlerQueryContext, EquipmentInteractionRegistry } from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; import { C3EmergencyMasterHandler, C3EM_TOGGLE_CHOICE_VALUE } from './c3-emergency-master.handler'; function fixture(initialStatus: C3EmergencyMasterStatus = 'dormant') { @@ -22,11 +24,9 @@ function fixture(initialStatus: C3EmergencyMasterStatus = 'dormant') { const owner = { id: 'emergency-unit', readOnly: () => false, - rules: createTestEquipmentRules({ - getEquipmentStatus: (entry: MountedEquipment) => ( - entry.committedDestroyed() ? 'destroyed' : 'available' - ), - }), + getEquipmentStatus: (entry: MountedEquipment) => entry.committedDestroyed() ? 'destroyed' : 'available', + isEquipmentOperational: (entry: MountedEquipment) => !entry.committedDestroyed(), + canPerformEquipmentAction: (entry: MountedEquipment) => !entry.committedDestroyed(), getInventory: () => [equipment], setInventoryEntry: jasmine.createSpy('setInventoryEntry'), getNotificationDisplayName: () => 'Emergency Unit', @@ -45,10 +45,16 @@ function fixture(initialStatus: C3EmergencyMasterStatus = 'dormant') { equipment: new MiscEquipment({ id: 'c3em', name: 'C3 Emergency Master', type: 'misc', flags: ['F_C3S', 'F_C3EM'] }), states: new Map(), }); + const toastService = jasmine.createSpyObj('ToastService', ['showToast']); const context = { - toastService: { showToast: jasmine.createSpy('showToast') }, - choiceSurface: 'turn-summary', - } as unknown as HandlerContext; + query: createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY, 'turn-summary'), + command: createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + toastService, + jasmine.createSpyObj('DialogsService', ['createDialog']), + ), + toastService, + }; return { equipment, owner, force, context, setStatus: (value: C3EmergencyMasterStatus) => { status = value; } }; } @@ -57,7 +63,7 @@ describe('C3EmergencyMasterHandler', () => { it('renders an empty track and a gray inactive EMERGENCY toggle with no consumed turns', () => { const { equipment, context } = fixture(); - const choices = handler.getChoices(equipment, context); + const choices = handler.getChoices(equipment, context.query); expect(choices.map(choice => choice.label)).toEqual(['1', '2', '3', '4', '5', '6', '!!', 'EMERGENCY']); expect(choices.slice(0, 7).every(choice => !choice.active)).toBeTrue(); @@ -71,7 +77,7 @@ describe('C3EmergencyMasterHandler', () => { const { equipment, context, setStatus } = fixture('dormant'); equipment.setState(C3EM_OPERATING_TURNS_STATE_KEY, '4'); - let choices = handler.getChoices(equipment, context); + let choices = handler.getChoices(equipment, context.query); expect(choices.slice(0, 3).every(choice => choice.active && choice.selectionTone === 'muted')).toBeTrue(); expect(choices[3]).toEqual(jasmine.objectContaining({ active: true, selectionTone: 'muted' })); @@ -80,7 +86,7 @@ describe('C3EmergencyMasterHandler', () => { expect(equipment.states.get(C3EM_OPERATING_TURNS_STATE_KEY)).toBe('4'); setStatus('active'); - choices = handler.getChoices(equipment, context); + choices = handler.getChoices(equipment, context.query); expect(choices[3]).toEqual(jasmine.objectContaining({ active: true, selectionTone: 'selected' })); expect(choices[4].active).toBeFalse(); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(4); @@ -90,13 +96,13 @@ describe('C3EmergencyMasterHandler', () => { const { equipment, context, setStatus } = fixture('active'); equipment.setState(C3EM_OPERATING_TURNS_STATE_KEY, '1'); - let choices = handler.getChoices(equipment, context); + let choices = handler.getChoices(equipment, context.query); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(1); expect(choices[0]).toEqual(jasmine.objectContaining({ active: true, selectionTone: 'selected' })); expect(choices[1].active).toBeFalse(); - handler.onEndTurn(equipment, context); - choices = handler.getChoices(equipment, context); + handler.onEndTurn(equipment, context.toastService); + choices = handler.getChoices(equipment, context.query); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(2); expect(choices[0]).toEqual(jasmine.objectContaining({ active: true, selectionTone: 'muted' })); expect(choices[1]).toEqual(jasmine.objectContaining({ active: true, selectionTone: 'selected' })); @@ -106,23 +112,23 @@ describe('C3EmergencyMasterHandler', () => { ); setStatus('standby'); - choices = handler.getChoices(equipment, context); + choices = handler.getChoices(equipment, context.query); expect(choices[0]).toEqual(jasmine.objectContaining({ active: true, selectionTone: 'muted' })); expect(choices[1]).toEqual(jasmine.objectContaining({ active: true, selectionTone: 'muted' })); }); it('allows manual override on and off without resetting consumed turns', () => { const { equipment, owner, context, setStatus } = fixture(); - const toggle = handler.getChoices(equipment, context).at(-1)!; + const toggle = handler.getChoices(equipment, context.query).at(-1)!; - handler.handleSelection(equipment, toggle, context); + handler.handleSelection(equipment, toggle, context.command); expect(getC3EmergencyMasterMode(equipment)).toBe('on'); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(1); expect(context.toastService.showToast).not.toHaveBeenCalled(); setStatus('active'); - handler.onEndTurn(equipment, context); + handler.onEndTurn(equipment, context.toastService); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(2); - handler.handleSelection(equipment, handler.getChoices(equipment, context).at(-1)!, context); + handler.handleSelection(equipment, handler.getChoices(equipment, context.query).at(-1)!, context.command); expect(getC3EmergencyMasterMode(equipment)).toBe('off'); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(2); @@ -132,12 +138,12 @@ describe('C3EmergencyMasterHandler', () => { it('owns activation transition notifications for manual and automatic status changes', () => { const { force, context, setStatus } = fixture('dormant'); - handler.onForceRuntimeChanged(force as never, context); + handler.onForceRuntimeChanged(force as never, context.toastService); expect(context.toastService.showToast).not.toHaveBeenCalled(); setStatus('active'); - handler.onForceRuntimeChanged(force as never, context); - handler.onForceRuntimeChanged(force as never, context); + handler.onForceRuntimeChanged(force as never, context.toastService); + handler.onForceRuntimeChanged(force as never, context.toastService); expect(context.toastService.showToast).toHaveBeenCalledOnceWith( 'Emergency Unit: C3 Emergency Master EMERGENCY active', 'info', @@ -145,16 +151,16 @@ describe('C3EmergencyMasterHandler', () => { ); setStatus('dormant'); - handler.onForceRuntimeChanged(force as never, context); + handler.onForceRuntimeChanged(force as never, context.toastService); setStatus('active'); - handler.onForceRuntimeChanged(force as never, context); + handler.onForceRuntimeChanged(force as never, context.toastService); expect(context.toastService.showToast).toHaveBeenCalledTimes(2); }); it('initializes an already active emergency master without a load-time activation toast', () => { const { force, equipment, owner, context } = fixture('active'); - handler.onForceRuntimeChanged(force as never, context); + handler.onForceRuntimeChanged(force as never, context.toastService); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(1); expect(owner.setInventoryEntry).toHaveBeenCalledOnceWith(equipment); @@ -165,15 +171,15 @@ describe('C3EmergencyMasterHandler', () => { const { equipment, owner, context, setStatus } = fixture('active'); equipment.setState(C3EM_OPERATING_TURNS_STATE_KEY, '1'); - handler.onEndTurn(equipment, context); + handler.onEndTurn(equipment, context.toastService); setStatus('standby'); - handler.onEndTurn(equipment, context); + handler.onEndTurn(equipment, context.toastService); setStatus('dormant'); - handler.onEndTurn(equipment, context); + handler.onEndTurn(equipment, context.toastService); setStatus('unavailable'); - handler.onEndTurn(equipment, context); + handler.onEndTurn(equipment, context.toastService); setStatus('active'); - handler.onEndTurn(equipment, context); + handler.onEndTurn(equipment, context.toastService); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(3); expect(owner.setInventoryEntry).toHaveBeenCalledTimes(2); @@ -184,15 +190,16 @@ describe('C3EmergencyMasterHandler', () => { equipment.setState(C3EM_OPERATING_TURNS_STATE_KEY, '1'); for (let turn = 2; turn <= 7; turn++) { - handler.onEndTurn(equipment, context); + handler.onEndTurn(equipment, context.toastService); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(turn); if (turn === 7) setStatus('fried'); } - handler.onEndTurn(equipment, context); - const choices = handler.getChoices(equipment, context); + handler.onEndTurn(equipment, context.toastService); + const choices = handler.getChoices(equipment, context.query); expect(isC3EmergencyMasterFried(equipment)).toBeTrue(); - expect(choices[6]).toEqual(jasmine.objectContaining({ active: true, disabled: false, selectionTone: 'selected' })); + expect(choices[6]).toEqual(jasmine.objectContaining({ active: true, selectionTone: 'selected' })); + expect(choices[6].disabled).toBeFalsy(); expect(choices[6].colors).toEqual(jasmine.objectContaining({ selected: '#f00', selectedText: '#fff' })); expect(choices.at(-1)).toEqual(jasmine.objectContaining({ active: false, disabled: true })); expect(choices.slice(0, 7).every(choice => !choice.disabled)).toBeTrue(); @@ -203,13 +210,13 @@ describe('C3EmergencyMasterHandler', () => { const { equipment, owner, context, setStatus } = fixture('fried'); equipment.setState(C3EM_OPERATING_TURNS_STATE_KEY, '7'); - handler.handleSelection(equipment, handler.getChoices(equipment, context)[3], context); + handler.handleSelection(equipment, handler.getChoices(equipment, context.query)[3], context.command); setStatus('dormant'); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(4); expect(isC3EmergencyMasterFried(equipment)).toBeFalse(); expect(owner.setInventoryEntry).toHaveBeenCalledWith(equipment); - const choices = handler.getChoices(equipment, context); + const choices = handler.getChoices(equipment, context.query); expect(choices[6].active).toBeFalse(); expect(choices.slice(0, 4).every(choice => choice.active)).toBeTrue(); expect(choices.at(-1)?.disabled).toBeFalse(); @@ -225,7 +232,7 @@ describe('C3EmergencyMasterHandler', () => { expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(7); equipment.deleteState(C3EM_OPERATING_TURNS_STATE_KEY); - handler.handleSelection(equipment, handler.getChoices(equipment, context)[2], context); + handler.handleSelection(equipment, handler.getChoices(equipment, context.query)[2], context.command); expect(equipment.states.get(C3EM_OPERATING_TURNS_STATE_KEY)).toBe('3'); }); @@ -233,7 +240,7 @@ describe('C3EmergencyMasterHandler', () => { const { equipment, owner, context } = fixture(); for (const value of ['invalid', Number.NaN, 0, -1, 1.5, 8]) { - handler.handleSelection(equipment, { label: String(value), value }, context); + handler.handleSelection(equipment, { label: String(value), value }, context.command); } expect(equipment.states.has(C3EM_OPERATING_TURNS_STATE_KEY)).toBeFalse(); @@ -244,10 +251,10 @@ describe('C3EmergencyMasterHandler', () => { it('maps active track buttons to the selected displayed turn without an offset', () => { const { equipment, context } = fixture('active'); - handler.handleSelection(equipment, handler.getChoices(equipment, context)[1], context); + handler.handleSelection(equipment, handler.getChoices(equipment, context.query)[1], context.command); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(2); - const choices = handler.getChoices(equipment, context); + const choices = handler.getChoices(equipment, context.query); expect(choices[0]).toEqual(jasmine.objectContaining({ active: true, selectionTone: 'muted' })); expect(choices[1]).toEqual(jasmine.objectContaining({ active: true, selectionTone: 'selected' })); expect(choices[2].active).toBeFalse(); @@ -257,18 +264,18 @@ describe('C3EmergencyMasterHandler', () => { const { equipment, context } = fixture('active'); equipment.setState(C3EM_OPERATING_TURNS_STATE_KEY, '4'); - handler.handleSelection(equipment, handler.getChoices(equipment, context)[0], context); + handler.handleSelection(equipment, handler.getChoices(equipment, context.query)[0], context.command); expect(getC3EmergencyMasterOperatingTurns(equipment)).toBe(1); expect(equipment.states.get(C3EM_OPERATING_TURNS_STATE_KEY)).toBe('1'); - expect(handler.getChoices(equipment, context)[0]).toEqual( + expect(handler.getChoices(equipment, context.query)[0]).toEqual( jasmine.objectContaining({ active: true, selectionTone: 'selected' }) ); }); it('follows direct sequence values across Emergency toggles, frying, and corrections', () => { const { equipment, context, setStatus } = fixture('dormant'); - const track = () => handler.getChoices(equipment, context); + const track = () => handler.getChoices(equipment, context.query); const expectTrack = (active: number[], selected?: number) => { const choices = track(); expect(choices.slice(0, 7).map(choice => choice.active)).toEqual( @@ -279,59 +286,64 @@ describe('C3EmergencyMasterHandler', () => { ); }; - handler.handleSelection(equipment, track().at(-1)!, context); + handler.handleSelection(equipment, track().at(-1)!, context.command); setStatus('active'); expectTrack([1], 1); - handler.handleSelection(equipment, track().at(-1)!, context); + handler.handleSelection(equipment, track().at(-1)!, context.command); setStatus('dormant'); expectTrack([1]); - handler.handleSelection(equipment, track().at(-1)!, context); + handler.handleSelection(equipment, track().at(-1)!, context.command); setStatus('active'); expectTrack([1], 1); - handler.handleSelection(equipment, track()[2], context); + handler.handleSelection(equipment, track()[2], context.command); expectTrack([1, 2, 3], 3); - handler.handleSelection(equipment, track().at(-1)!, context); + handler.handleSelection(equipment, track().at(-1)!, context.command); setStatus('dormant'); expectTrack([1, 2, 3]); setStatus('active'); - handler.handleSelection(equipment, track()[6], context); + handler.handleSelection(equipment, track()[6], context.command); setStatus('fried'); expect(getC3EmergencyMasterMode(equipment)).toBe('off'); expectTrack([7], 7); - handler.handleSelection(equipment, track()[5], context); + handler.handleSelection(equipment, track()[5], context.command); setStatus('dormant'); expect(isC3EmergencyMasterFried(equipment)).toBeFalse(); expect(getC3EmergencyMasterMode(equipment)).toBe('off'); expectTrack([1, 2, 3, 4, 5, 6]); - handler.handleSelection(equipment, track().at(-1)!, context); + handler.handleSelection(equipment, track().at(-1)!, context.command); setStatus('active'); expectTrack([1, 2, 3, 4, 5, 6], 6); - handler.handleSelection(equipment, track()[4], context); + handler.handleSelection(equipment, track()[4], context.command); expectTrack([1, 2, 3, 4, 5], 5); }); it('does not mutate unavailable or read-only equipment and blocks fried Emergency activation', () => { + const registry = new EquipmentInteractionRegistry(); + registry.register(handler); const unavailable = fixture(); unavailable.equipment.setCommittedDestroyed(true); - handler.handleSelection(unavailable.equipment, { label: 'EMERGENCY', value: C3EM_TOGGLE_CHOICE_VALUE }, unavailable.context); + const unavailableChoice = registry.getChoices(unavailable.equipment, unavailable.context.query).at(-1)!; + expect(registry.handleSelection(unavailable.equipment, unavailableChoice, unavailable.context.command)).toBeFalse(); expect(unavailable.equipment.states.has(C3EM_MODE_STATE_KEY)).toBeFalse(); const readOnly = fixture(); Object.assign(readOnly.owner, { readOnly: () => true }); - handler.handleSelection(readOnly.equipment, { label: 'EMERGENCY', value: C3EM_TOGGLE_CHOICE_VALUE }, readOnly.context); + const readOnlyChoice = registry.getChoices(readOnly.equipment, readOnly.context.query).at(-1)!; + expect(registry.handleSelection(readOnly.equipment, readOnlyChoice, readOnly.context.command)).toBeFalse(); expect(readOnly.equipment.states.has(C3EM_MODE_STATE_KEY)).toBeFalse(); const fried = fixture('fried'); fried.equipment.setState(C3EM_OPERATING_TURNS_STATE_KEY, '7'); - handler.handleSelection(fried.equipment, { label: 'EMERGENCY', value: C3EM_TOGGLE_CHOICE_VALUE }, fried.context); + const friedChoice = registry.getChoices(fried.equipment, fried.context.query).at(-1)!; + expect(registry.handleSelection(fried.equipment, friedChoice, fried.context.command)).toBeFalse(); expect(fried.equipment.states.has(C3EM_MODE_STATE_KEY)).toBeFalse(); }); }); diff --git a/src/app/equipment-handlers/c3-emergency-master.handler.ts b/src/app/equipment-handlers/c3-emergency-master.handler.ts index 3b860d61e..5f82824a7 100644 --- a/src/app/equipment-handlers/c3-emergency-master.handler.ts +++ b/src/app/equipment-handlers/c3-emergency-master.handler.ts @@ -17,7 +17,7 @@ import { } from '../models/c3-emergency-master.model'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { Force } from '../models/force.model'; -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerNotifications, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; export const C3_EMERGENCY_MASTER_HANDLER_ID = 'c3-emergency-master-handler'; export const C3EM_TOGGLE_CHOICE_VALUE = 'c3em-emergency'; @@ -51,10 +51,9 @@ export class C3EmergencyMasterHandler extends EquipmentInteractionHandler { return equipment.owner.force.c3Network().emergencyMasterStatus(equipment); } - getChoices(equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { const turns = C3EmergencyMasterHandler.operatingTurns(equipment); const status = C3EmergencyMasterHandler.status(equipment); - const unavailable = equipment.owner.readOnly() || equipment.isUnavailable(); const track = TRACK_LABELS.map((label, index): PickerChoice => { const sequenceValue = index + 1; const friedChoice = sequenceValue === C3EM_FRIED_SEQUENCE_VALUE; @@ -64,7 +63,6 @@ export class C3EmergencyMasterHandler extends EquipmentInteractionHandler { shortLabel: label, value: sequenceValue, displayType: 'toggle', - disabled: unavailable, active: friedChoice ? current : !isC3EmergencyMasterFried(equipment) && sequenceValue <= turns, selectionTone: current && (friedChoice || status === 'active') ? 'selected' : 'muted', colors: friedChoice ? FRIED_COLORS : TRACK_COLORS, @@ -76,7 +74,7 @@ export class C3EmergencyMasterHandler extends EquipmentInteractionHandler { shortLabel: 'EMERGENCY', value: C3EM_TOGGLE_CHOICE_VALUE, displayType: 'toggle', - disabled: unavailable || isC3EmergencyMasterFried(equipment), + disabled: isC3EmergencyMasterFried(equipment), active: status === 'active' || status === 'standby', selectionTone: 'selected', colors: EMERGENCY_COLORS, @@ -84,8 +82,7 @@ export class C3EmergencyMasterHandler extends EquipmentInteractionHandler { }]; } - handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerContext): boolean { - if (equipment.owner.readOnly() || equipment.isUnavailable()) return true; + handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): boolean { if (choice.value === C3EM_TOGGLE_CHOICE_VALUE && isC3EmergencyMasterFried(equipment)) return true; const status = C3EmergencyMasterHandler.status(equipment); let changed: boolean; @@ -115,7 +112,7 @@ export class C3EmergencyMasterHandler extends EquipmentInteractionHandler { return true; } - override onEndTurn(equipment: MountedEquipment, context: HandlerContext): void { + override onEndTurn(equipment: MountedEquipment, notifications: HandlerNotifications): void { if (C3EmergencyMasterHandler.status(equipment) !== 'active') return; const nextTurns = C3EmergencyMasterHandler.operatingTurns(equipment) + 1; if (!this.setOperatingTurns(equipment, nextTurns)) return; @@ -123,13 +120,13 @@ export class C3EmergencyMasterHandler extends EquipmentInteractionHandler { equipment.setState(C3EM_MODE_STATE_KEY, 'off'); } equipment.owner.setInventoryEntry(equipment); - context.toastService.showToast( + notifications.showToast( `${equipment.owner.getNotificationDisplayName()}: ${equipment.equipment?.name || equipment.name} ${this.statusLabel(equipment)}`, nextTurns === C3EM_FRIED_SEQUENCE_VALUE ? 'error' : 'info' ); } - override onForceRuntimeChanged(force: Force, context: HandlerContext): void { + override onForceRuntimeChanged(force: Force, notifications: HandlerNotifications): void { const network = force.c3Network(); const equipmentByKey = new Map(); const statuses = force.units().flatMap(unit => { @@ -156,7 +153,7 @@ export class C3EmergencyMasterHandler extends EquipmentInteractionHandler { equipment.owner.setInventoryEntry(equipment); } if (!activatedKeys.has(key)) continue; - context.toastService.showToast( + notifications.showToast( `${equipment.owner.getNotificationDisplayName()}: ${equipment.equipment?.name || equipment.name} EMERGENCY active`, 'info', `c3em-activation-${force.instanceId() ?? force.name}-${key}` diff --git a/src/app/equipment-handlers/c3.handler.ts b/src/app/equipment-handlers/c3.handler.ts index 59f7643a6..182273329 100644 --- a/src/app/equipment-handlers/c3.handler.ts +++ b/src/app/equipment-handlers/c3.handler.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerChoice, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { PickerChoice } from '../components/picker/picker.interface'; import { firstValueFrom } from 'rxjs'; @@ -13,18 +13,19 @@ export class C3Handler extends EquipmentInteractionHandler { override readonly flags: EquipmentFlag[] = ['ANY_C3']; override readonly priority = 10; - getChoices(equipment: MountedEquipment, context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): HandlerChoice[] { return [ { label: 'Configure', value: 'c3-network-configuration', - disabled: equipment.isUnavailable(), + action: 'configure-network', + readOnlySafe: _context.isReadOnly(equipment), displayType: 'button' } ]; } - async handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerContext): Promise { + async handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): Promise { if (choice.value !== 'c3-network-configuration') return false; const force = equipment.owner.force; @@ -33,10 +34,11 @@ export class C3Handler extends EquipmentInteractionHandler { const { C3NetworkDialogComponent } = await import('../components/c3-network-dialog/c3-network-dialog.component'); type C3NetworkDialogData = import('../components/c3-network-dialog/c3-network-dialog.component').C3NetworkDialogData; type C3NetworkDialogResult = import('../components/c3-network-dialog/c3-network-dialog.component').C3NetworkDialogResult; + const readOnly = equipment.owner.readOnly(); const ref = context.dialogsService.createDialog(C3NetworkDialogComponent, { data: { force: force, - readOnly: equipment.owner.readOnly() + readOnly }, width: '100dvw', height: '100dvh', @@ -46,7 +48,7 @@ export class C3Handler extends EquipmentInteractionHandler { }); const result = await firstValueFrom(ref.closed); - if (result?.updated) { + if (!readOnly && result?.updated) { force.setNetwork(result.networks); context.toastService.showToast('C3 network configuration changed', 'success'); } diff --git a/src/app/equipment-handlers/disabled-equipment.handler.spec.ts b/src/app/equipment-handlers/disabled-equipment.handler.spec.ts index 3a4428e1e..e75ae8544 100644 --- a/src/app/equipment-handlers/disabled-equipment.handler.spec.ts +++ b/src/app/equipment-handlers/disabled-equipment.handler.spec.ts @@ -3,25 +3,37 @@ // Author: Drake import { EquipmentFlag } from '../models/equipment-flags.type'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import type { Equipment } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { ENTRY_DISABLED_STATE_KEY } from '../models/rules/unit-type-rules'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionRegistry, +} from '../services/equipment-interaction-registry.service'; +import type { DialogsService } from '../services/dialogs.service'; +import type { ToastService } from '../services/toast.service'; import { DisabledEquipmentHandler, isEquipmentDisabledByFailure } from './disabled-equipment.handler'; function owner() { + const getEquipmentStatus = (entry: MountedEquipment) => ( + entry.committedDestroyed() + ? 'destroyed' + : isEquipmentDisabledByFailure(entry) + ? 'disabled' + : 'available' + ); return { + readOnly: () => false, setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: createTestEquipmentRules({ - getEquipmentStatus: (entry: MountedEquipment) => ( - entry.committedDestroyed() - ? 'destroyed' - : isEquipmentDisabledByFailure(entry) - ? 'disabled' - : 'available' - ), - }) + getEquipmentStatus, + isEquipmentOperational: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', + canPerformEquipmentAction: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', + canEditEquipmentState: (entry: MountedEquipment, edit: string) => { + const status = getEquipmentStatus(entry); + return edit === 'enable' ? status === 'disabled' : status === 'available'; + }, } as never; } @@ -38,13 +50,12 @@ function entry(flags: EquipmentFlag[], states = new Map(), destr describe('DisabledEquipmentHandler', () => { const handler = new DisabledEquipmentHandler(); - const context = { - toastService: { showToast: jasmine.createSpy('showToast') } - } as never as HandlerContext; - - beforeEach(() => { - context.toastService.showToast = jasmine.createSpy('showToast'); - }); + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast']), + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); it('applies to equipment with any disableable failure flag', () => { expect(handler.applicableTo(entry(['F_RADICAL_HEATSINK']))).toBeTrue(); @@ -61,21 +72,31 @@ describe('DisabledEquipmentHandler', () => { it('toggles disabled state and persists the inventory entry', () => { const mounted = entry(['F_RADICAL_HEATSINK']); - handler.handleSelection(mounted, handler.getChoices(mounted, context)[0], context); + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); expect(mounted.states.get(ENTRY_DISABLED_STATE_KEY)).toBe('true'); expect(mounted.owner.setInventoryEntry).toHaveBeenCalledWith(mounted); - expect(mounted.owner.rules.getEquipmentStatus(mounted)).toBe('disabled'); + expect(mounted.owner.getEquipmentStatus(mounted)).toBe('disabled'); - handler.handleSelection(mounted, handler.getChoices(mounted, context)[0], context); + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); expect(mounted.states.has(ENTRY_DISABLED_STATE_KEY)).toBeFalse(); - expect(mounted.owner.rules.getEquipmentStatus(mounted)).toBe('available'); + expect(mounted.owner.getEquipmentStatus(mounted)).toBe('available'); }); it('keeps the toggle available while the entry is disabled by this handler', () => { const mounted = entry(['F_RADICAL_HEATSINK'], new Map([[ENTRY_DISABLED_STATE_KEY, 'true']])); + const registry = new EquipmentInteractionRegistry(); + registry.register(handler); - expect(handler.getChoices(mounted, context)[0]).toEqual(jasmine.objectContaining({ active: true, disabled: false })); + expect(handler.getChoices(mounted, queryContext)[0]).toEqual(jasmine.objectContaining({ + active: true, + stateEdit: 'enable', + })); + expect(registry.getChoices(mounted, queryContext)[0]).toEqual(jasmine.objectContaining({ + active: true, + stateEdit: 'enable', + disabled: false, + })); }); -}); \ No newline at end of file +}); diff --git a/src/app/equipment-handlers/disabled-equipment.handler.ts b/src/app/equipment-handlers/disabled-equipment.handler.ts index 765d7a058..a8ad626c8 100644 --- a/src/app/equipment-handlers/disabled-equipment.handler.ts +++ b/src/app/equipment-handlers/disabled-equipment.handler.ts @@ -8,7 +8,7 @@ import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE, } from '../models/rules/unit-type-rules'; -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerChoice, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; const DISABLEABLE_EQUIPMENT_FLAGS = ['F_RADICAL_HEATSINK'] as const; @@ -26,20 +26,20 @@ export abstract class DisabledStateToggleHandler extends EquipmentInteractionHan override readonly priority = 10; - getChoices(equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): HandlerChoice[] { const disabled = isEquipmentDisabledByFailure(equipment); return [{ label: disabled ? this.disabledLabel : this.enabledLabel, shortLabel: disabled ? this.disabledShortLabel : this.enabledShortLabel, value: disabled ? 'false' : ENTRY_DISABLED_STATE_VALUE, + stateEdit: disabled ? 'enable' : 'disable', displayType: 'toggle', - disabled: equipment.isDestroyed(), active: disabled, tooltipType: disabled ? 'error' : undefined }]; } - handleSelection(equipment: MountedEquipment, _choice: PickerChoice, context: HandlerContext): boolean { + handleSelection(equipment: MountedEquipment, _choice: PickerChoice, context: HandlerCommandContext): boolean { const disabled = isEquipmentDisabledByFailure(equipment); const changed = disabled ? equipment.deleteState(ENTRY_DISABLED_STATE_KEY) @@ -62,4 +62,4 @@ export class DisabledEquipmentHandler extends DisabledStateToggleHandler { const flags = equipment.equipment?.flags; return !!flags && DISABLEABLE_EQUIPMENT_FLAGS.some(flag => flags.has(flag)); } -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/ecm.handler.ts b/src/app/equipment-handlers/ecm.handler.ts index 3e6b23785..f6352fae7 100644 --- a/src/app/equipment-handlers/ecm.handler.ts +++ b/src/app/equipment-handlers/ecm.handler.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { PickerChoice, PickerValue } from '../components/picker/picker.interface'; import { ECMMode } from '../models/common.model'; @@ -45,7 +45,7 @@ export class ECMHandler extends EquipmentInteractionHandler { return modes; } - getChoices(equipment: MountedEquipment, context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { const currentState = equipment.states?.get(this.stateKey) || this.getDefaultMode(); const modes = this.getModes(equipment); @@ -55,13 +55,12 @@ export class ECMHandler extends EquipmentInteractionHandler { value: currentState, displayType: 'dropdown', choices: modes, - disabled: equipment.isUnavailable(), keepOpen: true } ]; } - handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerContext): boolean { + handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): boolean { equipment.states?.set(this.stateKey, String(choice.value)); equipment.owner.setInventoryEntry(equipment); context.toastService.showToast( @@ -75,4 +74,4 @@ export class ECMHandler extends EquipmentInteractionHandler { const ecmMode = equipment.states?.get(this.stateKey); return (ecmMode || ECMMode.ECM) !== ECMMode.OFF; } -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/escalatingfailure.handler.ts b/src/app/equipment-handlers/escalatingfailure.handler.ts index 8c24edeb6..5077be1ed 100644 --- a/src/app/equipment-handlers/escalatingfailure.handler.ts +++ b/src/app/equipment-handlers/escalatingfailure.handler.ts @@ -6,7 +6,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { EquipmentFlag } from '../models/equipment-flags.type'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../models/rules/unit-type-rules'; -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerChoice, type HandlerCommandContext, type HandlerNotifications, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import { isEquipmentDisabledByFailure } from './disabled-equipment.handler'; export const ESCALATING_FAILURE_HANDLER_ID = 'escalating-failure-handler'; @@ -80,7 +80,7 @@ export class EscalatingFailureHandler extends EquipmentInteractionHandler { } protected isSequenceButtonClickable(equipment: MountedEquipment, index: number): boolean { - return this.canUseHandler(equipment) && !isEquipmentDisabledByFailure(equipment) && !equipment.isUnavailable() + return this.canUseHandler(equipment) && !isEquipmentDisabledByFailure(equipment) && index >= 0 && index < this.getSequenceLabels(equipment).length && index <= this.getSequenceState(equipment); } @@ -109,7 +109,7 @@ export class EscalatingFailureHandler extends EquipmentInteractionHandler { return sequenceChanged || activeChanged; } - getChoices(equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, context: HandlerQueryContext): HandlerChoice[] { if (!this.canUseHandler(equipment)) return []; const state = this.getSequenceState(equipment); const active = this.isActive(equipment); @@ -125,7 +125,7 @@ export class EscalatingFailureHandler extends EquipmentInteractionHandler { keepOpen: true, })); const disabled = isEquipmentDisabledByFailure(equipment); - const toggleLabel = _context.choiceSurface === 'turn-summary' + const toggleLabel = context.choiceSurface === 'turn-summary' ? '✖' : disabled ? 'Malfunctioning' : 'Operational'; return [...sequenceChoices, { @@ -133,14 +133,14 @@ export class EscalatingFailureHandler extends EquipmentInteractionHandler { shortLabel: toggleLabel, value: ESCALATING_FAILURE_DISABLED_CHOICE_VALUE, displayType: 'toggle', - disabled: equipment.isDestroyed(), + stateEdit: disabled ? 'enable' : 'disable', active: disabled, colors: disabled ? ESCALATING_FAILURE_FAILURE_CHOICE_COLORS : undefined, tooltipType: disabled ? 'error' : undefined, }]; } - handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerContext): boolean { + handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): boolean { if (!this.canUseHandler(equipment)) return true; if (choice.value === ESCALATING_FAILURE_DISABLED_CHOICE_VALUE) { const disabled = isEquipmentDisabledByFailure(equipment); @@ -171,7 +171,7 @@ export class EscalatingFailureHandler extends EquipmentInteractionHandler { return true; } - override onEndTurn(equipment: MountedEquipment, context: HandlerContext): void { + override onEndTurn(equipment: MountedEquipment, notifications: HandlerNotifications): void { if (isEquipmentDisabledByFailure(equipment)) return; if (this.isActive(equipment)) { const changed = this.setActive(equipment, false); @@ -185,10 +185,10 @@ export class EscalatingFailureHandler extends EquipmentInteractionHandler { const changed = this.setSequenceState(equipment, currentState - 1); if (changed) { equipment.owner.setInventoryEntry(equipment); - context.toastService.showToast( + notifications.showToast( `${equipment.owner.getNotificationDisplayName()}: ${equipment.equipment?.name || equipment.name} sequence reduced to ${this.getSequenceState(equipment)}`, 'info' ); } } -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/hag.handler.spec.ts b/src/app/equipment-handlers/hag.handler.spec.ts index de6fae902..ec8d05f66 100644 --- a/src/app/equipment-handlers/hag.handler.spec.ts +++ b/src/app/equipment-handlers/hag.handler.spec.ts @@ -3,18 +3,20 @@ // Author: Drake import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import type { WeaponType } from '../models/weapon-types.model'; import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import { createHandlerCommandContext, createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import type { DialogsService } from '../services/dialogs.service'; +import type { ToastService } from '../services/toast.service'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { HAG_FLAK_MODE, HAG_STANDARD_MODE, HagHandler, selectedHagMode } from './hag.handler'; function owner() { return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - isEquipmentActionUnavailable: jasmine.createSpy('isEquipmentActionUnavailable').and.returnValue(false), - rules: createTestEquipmentRules() + isEquipmentOperational: jasmine.createSpy('isEquipmentOperational').and.returnValue(true), + canPerformEquipmentAction: jasmine.createSpy('canPerformEquipmentAction').and.returnValue(true), } as never; } @@ -40,9 +42,12 @@ function hag(mode?: string): MountedWeapon { }); } -function context(): HandlerContext { - return {} as HandlerContext; -} +const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); +const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast']), + jasmine.createSpyObj('DialogsService', ['createDialog']), +); describe('HagHandler', () => { const handler = new HagHandler(); @@ -51,7 +56,7 @@ describe('HagHandler', () => { const entry = hag('invalid'); expect(selectedHagMode(entry)).toBe(HAG_STANDARD_MODE); - expect(handler.getChoices(entry, context())).toEqual([jasmine.objectContaining({ + expect(handler.getChoices(entry, queryContext)).toEqual([jasmine.objectContaining({ label: 'Mode', value: HAG_STANDARD_MODE, displayType: 'dropdown', @@ -65,7 +70,7 @@ describe('HagHandler', () => { it('persists the selected mode through the inventory-control state', () => { const entry = hag(); - expect(handler.handleSelection(entry, { label: 'FLAK', value: HAG_FLAK_MODE }, context())).toBeTrue(); + expect(handler.handleSelection(entry, { label: 'FLAK', value: HAG_FLAK_MODE }, commandContext)).toBeTrue(); expect(entry.states.get(INVENTORY_CONTROL_MODE_STATE)).toBe(HAG_FLAK_MODE); expect(entry.owner.setInventoryEntry).toHaveBeenCalledWith(entry); @@ -74,16 +79,16 @@ describe('HagHandler', () => { it('keeps DB only in STD and replaces it with F in FLAK', () => { const baseTypes = new Set(['C', 'DB', 'F', 'X']); - expect(handler.applyInventoryControlWeaponTypes(hag(HAG_STANDARD_MODE), baseTypes, context())) + expect(handler.applyInventoryControlWeaponTypes(hag(HAG_STANDARD_MODE), baseTypes, queryContext)) .toEqual(new Set(['C', 'DB', 'X'])); - expect(handler.applyInventoryControlWeaponTypes(hag(HAG_FLAK_MODE), baseTypes, context())) + expect(handler.applyInventoryControlWeaponTypes(hag(HAG_FLAK_MODE), baseTypes, queryContext)) .toEqual(new Set(['C', 'F', 'X'])); expect(baseTypes).toEqual(new Set(['C', 'DB', 'F', 'X'])); }); it('adds a -1 to-hit adjustment only in FLAK mode', () => { - expect(handler.getToHitAdjustments(hag(HAG_STANDARD_MODE), {}, context())).toEqual([]); - expect(handler.getToHitAdjustments(hag(HAG_FLAK_MODE), {}, context())) + expect(handler.getToHitAdjustments(hag(HAG_STANDARD_MODE), {}, queryContext)).toEqual([]); + expect(handler.getToHitAdjustments(hag(HAG_FLAK_MODE), {}, queryContext)) .toEqual([{ kind: 'add', label: 'HAG/20 (FLAK)', modifier: -1 }]); diff --git a/src/app/equipment-handlers/hag.handler.ts b/src/app/equipment-handlers/hag.handler.ts index 177f0b3bd..80d0bb839 100644 --- a/src/app/equipment-handlers/hag.handler.ts +++ b/src/app/equipment-handlers/hag.handler.ts @@ -8,7 +8,7 @@ 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'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext, type ToHitAdjustmentContext } from '../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE, setInventoryControlMode } from '../utils/inventory-control.util'; export const HAG_STANDARD_MODE = 'Standard'; @@ -23,7 +23,7 @@ export class HagHandler extends EquipmentInteractionHandler { return equipment.equipment instanceof WeaponEquipment; } - override getChoices(equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + override getChoices(equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { return [{ label: 'Mode', value: selectedHagMode(equipment), @@ -32,12 +32,11 @@ export class HagHandler extends EquipmentInteractionHandler { { label: 'STD', value: HAG_STANDARD_MODE }, { label: 'FLAK', value: HAG_FLAK_MODE } ], - disabled: equipment.isUnavailable(), keepOpen: true }]; } - override handleSelection(equipment: MountedEquipment, choice: PickerChoice, _context: HandlerContext): boolean { + override handleSelection(equipment: MountedEquipment, choice: PickerChoice, _context: HandlerCommandContext): boolean { setInventoryControlMode(equipment, String(choice.value)); return true; } @@ -45,7 +44,7 @@ export class HagHandler extends EquipmentInteractionHandler { override applyInventoryControlWeaponTypes( equipment: MountedEquipment, types: ReadonlySet, - _context: HandlerContext + _context: HandlerQueryContext ): ReadonlySet { const effectiveTypes = new Set(types); if (selectedHagMode(equipment) === HAG_FLAK_MODE) { @@ -60,7 +59,7 @@ export class HagHandler extends EquipmentInteractionHandler { override getToHitAdjustments( equipment: MountedEquipment, _adjustmentContext: ToHitAdjustmentContext, - _context: HandlerContext + _context: HandlerQueryContext ): readonly ToHitAdjustment[] { return selectedHagMode(equipment) === HAG_FLAK_MODE ? [{ @@ -76,4 +75,4 @@ export function selectedHagMode(equipment: MountedEquipment): string { return equipment.states.get(INVENTORY_CONTROL_MODE_STATE) === HAG_FLAK_MODE ? HAG_FLAK_MODE : HAG_STANDARD_MODE; -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/inventory-mode.handler.ts b/src/app/equipment-handlers/inventory-mode.handler.ts index 137acc638..d05515083 100644 --- a/src/app/equipment-handlers/inventory-mode.handler.ts +++ b/src/app/equipment-handlers/inventory-mode.handler.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import type { PickerChoice } from '../components/picker/picker.interface'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import { @@ -23,14 +23,14 @@ export class InventoryModeHandler extends EquipmentInteractionHandler { return getInventoryControlModes(equipment).length > 0; } - getChoices(equipment: MountedEquipment, context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, context: HandlerQueryContext): PickerChoice[] { const choices = getInventoryControlModeChoices(equipment); if (choices.length === 0) return []; const currentMode = getSelectedInventoryControlMode( equipment, - context.dataService.getEquipmentRegistry(), - equipment.owner.getInventoryControlRules?.() ?? {} + context.equipmentCatalog, + context.matchesAmmo ) ?? choices[0].value; return [ { @@ -38,13 +38,12 @@ export class InventoryModeHandler extends EquipmentInteractionHandler { value: currentMode, displayType: 'dropdown', choices, - disabled: equipment.isUnavailable(), keepOpen: true } ]; } - handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerContext): boolean { + handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): boolean { setInventoryControlMode(equipment, String(choice.value)); return true; } diff --git a/src/app/equipment-handlers/laser-insulator.handler.spec.ts b/src/app/equipment-handlers/laser-insulator.handler.spec.ts index 276e3e45e..c69f083f8 100644 --- a/src/app/equipment-handlers/laser-insulator.handler.spec.ts +++ b/src/app/equipment-handlers/laser-insulator.handler.spec.ts @@ -3,18 +3,17 @@ // Author: Drake import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import { createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; import { LaserInsulatorHandler } from './laser-insulator.handler'; function owner(unavailableEntry?: MountedEquipment) { return { - rules: createTestEquipmentRules({ - getEquipmentStatus: (candidate: MountedEquipment) => ( - candidate === unavailableEntry || candidate.committedDestroyed() ? 'destroyed' : 'available' - ), - }) + getEquipmentStatus: (candidate: MountedEquipment) => ( + candidate === unavailableEntry || candidate.committedDestroyed() ? 'destroyed' : 'available' + ), + isEquipmentOperational: (candidate: MountedEquipment) => candidate !== unavailableEntry && !candidate.committedDestroyed(), } as never; } @@ -22,13 +21,23 @@ function laser(insulator: MountedEquipment): MountedEquipment { return new MountedEquipment({ owner: owner(), id: 'laser', name: 'Laser', equipment: new WeaponEquipment({ id: 'laser', name: 'Laser', type: 'weapon', flags: ['F_ENERGY', 'F_LASER'], weapon: { ammoType: 'NA', heat: 3 } }), linkedWith: [insulator] }); } -function insulator(): MountedEquipment { - return new MountedEquipment({ owner: owner(), id: 'insulator', name: 'Laser Insulator', equipment: new MiscEquipment({ id: 'insulator', name: 'Laser Insulator', type: 'misc', flags: ['F_WEAPON_ENHANCEMENT', 'F_LASER_INSULATOR'] }) }); +function insulator(unavailable = false): MountedEquipment { + return new MountedEquipment({ + owner: { + getEquipmentStatus: (candidate: MountedEquipment) => ( + unavailable || candidate.committedDestroyed() ? 'destroyed' : 'available' + ), + isEquipmentOperational: (candidate: MountedEquipment) => !unavailable && !candidate.committedDestroyed(), + } as never, + id: 'insulator', + name: 'Laser Insulator', + equipment: new MiscEquipment({ id: 'insulator', name: 'Laser Insulator', type: 'misc', flags: ['F_WEAPON_ENHANCEMENT', 'F_LASER_INSULATOR'] }) + }); } describe('LaserInsulatorHandler', () => { const handler = new LaserInsulatorHandler(); - const context = {} as HandlerContext; + const context = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); it('reduces model heat while the insulator is available', () => { const linked = insulator(); @@ -38,8 +47,7 @@ describe('LaserInsulatorHandler', () => { }); it('does not reduce heat when the linked insulator is unavailable', () => { - const linked = insulator(); - linked.owner = owner(linked); + const linked = insulator(true); expect(handler.applyLinkedInventoryControlHeatEffects(linked, laser(linked), { value: 3, weakened: false }, context)) .toEqual({ value: 3, weakened: true }); @@ -60,4 +68,4 @@ describe('LaserInsulatorHandler', () => { expect(handler.applyLinkedInventoryControlHeatEffects(linked, weapon, { value: 3, weakened: false }, context)) .toEqual({ value: 3, weakened: false }); }); -}); \ No newline at end of file +}); diff --git a/src/app/equipment-handlers/laser-insulator.handler.ts b/src/app/equipment-handlers/laser-insulator.handler.ts index 6ccf2e885..2a69a98f8 100644 --- a/src/app/equipment-handlers/laser-insulator.handler.ts +++ b/src/app/equipment-handlers/laser-insulator.handler.ts @@ -5,18 +5,18 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { EquipmentFlag } from '../models/equipment-flags.type'; import type { MountedEquipment } from '../models/mounted-equipment.model'; -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import type { InventoryControlHeatEffect } from '../utils/inventory-control-heat.util'; export class LaserInsulatorHandler extends EquipmentInteractionHandler { readonly id = 'laser-insulator-handler'; override readonly flags: EquipmentFlag[] = ['F_WEAPON_ENHANCEMENT', 'F_LASER_INSULATOR']; - getChoices(_equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(_equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { return []; } - handleSelection(_equipment: MountedEquipment, _choice: PickerChoice, _context: HandlerContext): boolean { + handleSelection(_equipment: MountedEquipment, _choice: PickerChoice, _context: HandlerCommandContext): boolean { return false; } @@ -24,10 +24,10 @@ export class LaserInsulatorHandler extends EquipmentInteractionHandler { equipment: MountedEquipment, parent: MountedEquipment, effect: InventoryControlHeatEffect, - _context: HandlerContext + context: HandlerQueryContext ): InventoryControlHeatEffect { if (!this.isLaser(parent)) return effect; - return equipment.isUnavailable() + return context.getStatus(equipment) !== 'available' ? { ...effect, weakened: true } : { ...effect, value: Math.max(1, effect.value - 1), suffix: '*' }; } @@ -36,4 +36,4 @@ export class LaserInsulatorHandler extends EquipmentInteractionHandler { return equipment.equipment?.hasFlag('F_ENERGY') === true && equipment.equipment.hasFlag('F_LASER'); } -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/masc.handler.spec.ts b/src/app/equipment-handlers/masc.handler.spec.ts index 7ae3466f3..debd14373 100644 --- a/src/app/equipment-handlers/masc.handler.spec.ts +++ b/src/app/equipment-handlers/masc.handler.spec.ts @@ -2,13 +2,22 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake +import type { EquipmentStateEdit } from '../models/cbt-force-unit.model'; import { EquipmentFlag } from '../models/equipment-flags.type'; import { MiscEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; +import type { EquipmentStatus } from '../models/equipment-status.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; -import { ENTRY_DISABLED_STATE_KEY } from '../models/rules/unit-type-rules'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../models/rules/unit-type-rules'; +import type { DialogsService } from '../services/dialogs.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionRegistry, + type HandlerQueryContext, +} from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; import { MASC_ACTIVE_STATE_KEY, MASC_SEQUENCE_STATE_KEY, @@ -24,12 +33,20 @@ function owner( airborne: () => airborne, ...turnStateOverrides, }; + const getEquipmentStatus = (entry: MountedEquipment): EquipmentStatus => entry.committedDestroyed() + ? 'destroyed' + : entry.states.get(ENTRY_DISABLED_STATE_KEY) === ENTRY_DISABLED_STATE_VALUE + ? 'disabled' + : 'available'; return { - rules: createTestEquipmentRules({ - getEquipmentStatus: (entry: MountedEquipment) => ( - entry.committedDestroyed() ? 'destroyed' : 'available' - ), - }), + readOnly: () => false, + getEquipmentStatus, + isEquipmentOperational: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', + canPerformEquipmentAction: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', + canEditEquipmentState: (entry: MountedEquipment, edit: EquipmentStateEdit) => { + const status = getEquipmentStatus(entry); + return edit === 'enable' ? status === 'disabled' : status === 'available'; + }, gameRules, getNotificationDisplayName: () => 'Atlas AS7-D (Natasha Kerensky)', setInventoryEntry: jasmine.createSpy('setInventoryEntry'), @@ -51,18 +68,21 @@ function mascEntry( }); } -function context(choiceSurface: HandlerContext['choiceSurface'] = 'turn-summary'): HandlerContext { - return { - toastService: { showToast: jasmine.createSpy('showToast') }, - choiceSurface, - } as unknown as HandlerContext; +function queryContext(choiceSurface: HandlerQueryContext['choiceSurface'] = 'turn-summary'): HandlerQueryContext { + return createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY, choiceSurface); } +const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast']), + jasmine.createSpyObj('DialogsService', ['createDialog']), +); + describe('MascHandler', () => { const handler = new MascHandler(); it('starts with only the first sequence button clickable', () => { - const choices = handler.getChoices(mascEntry(), context()); + const choices = handler.getChoices(mascEntry(), queryContext()); expect(choices.slice(0, 5).map(choice => ({ label: choice.label, disabled: choice.disabled, active: choice.active, displayType: choice.displayType }))).toEqual([ { label: '3+', disabled: false, active: false, displayType: 'toggle' }, @@ -75,7 +95,7 @@ describe('MascHandler', () => { }); it('uses the Core2026 sequence progression for Core2026 units', () => { - const choices = handler.getChoices(mascEntry(['F_MASC'], null, {}, CORE_2026_GAME_RULES), context()); + const choices = handler.getChoices(mascEntry(['F_MASC'], null, {}, CORE_2026_GAME_RULES), queryContext()); expect(choices.slice(0, 5).map(choice => choice.label)).toEqual(['3+', '5+', '7+', '10+', '11+']); expect(choices[4].colors).toEqual(jasmine.objectContaining({ selected: 'var(--bt-yellow)' })); @@ -84,25 +104,25 @@ describe('MascHandler', () => { it('advances one step at a time and unlocks the next button', () => { const entry = mascEntry(); - handler.handleSelection(entry, handler.getChoices(entry, context())[0], context()); + handler.handleSelection(entry, handler.getChoices(entry, queryContext())[0], commandContext); expect(MascHandler.getSequenceState(entry)).toBe(1); expect(handler.isActive(entry)).toBeTrue(); - expect(handler.getChoices(entry, context()).slice(0, 5).map(choice => ({ disabled: choice.disabled, active: choice.active }))).toEqual([ + expect(handler.getChoices(entry, queryContext()).slice(0, 5).map(choice => ({ disabled: choice.disabled, active: choice.active }))).toEqual([ { disabled: false, active: true }, { disabled: false, active: false }, { disabled: true, active: false }, { disabled: true, active: false }, { disabled: true, active: false }, ]); - expect(handler.getChoices(entry, context()).slice(0, 5).map(choice => choice.selectionTone)).toEqual(['selected', 'muted', 'muted', 'muted', 'muted']); + expect(handler.getChoices(entry, queryContext()).slice(0, 5).map(choice => choice.selectionTone)).toEqual(['selected', 'muted', 'muted', 'muted', 'muted']); }); it('uses muted tone for previous buttons and inactive current button', () => { const entry = mascEntry(); MascHandler.setSequenceState(entry, 3); - expect(handler.getChoices(entry, context()).slice(0, 5).map(choice => ({ active: choice.active, tone: choice.selectionTone }))).toEqual([ + expect(handler.getChoices(entry, queryContext()).slice(0, 5).map(choice => ({ active: choice.active, tone: choice.selectionTone }))).toEqual([ { active: true, tone: 'muted' }, { active: true, tone: 'muted' }, { active: true, tone: 'muted' }, @@ -112,7 +132,7 @@ describe('MascHandler', () => { entry.setState(MASC_ACTIVE_STATE_KEY, 'true'); - expect(handler.getChoices(entry, context()).slice(0, 5).map(choice => ({ active: choice.active, tone: choice.selectionTone }))).toEqual([ + expect(handler.getChoices(entry, queryContext()).slice(0, 5).map(choice => ({ active: choice.active, tone: choice.selectionTone }))).toEqual([ { active: true, tone: 'muted' }, { active: true, tone: 'muted' }, { active: true, tone: 'selected' }, @@ -126,7 +146,7 @@ describe('MascHandler', () => { MascHandler.setSequenceState(entry, 3); entry.setState(MASC_ACTIVE_STATE_KEY, 'true'); - handler.handleSelection(entry, handler.getChoices(entry, context())[2], context()); + handler.handleSelection(entry, handler.getChoices(entry, queryContext())[2], commandContext); expect(MascHandler.getSequenceState(entry)).toBe(3); expect(handler.isActive(entry)).toBeFalse(); @@ -137,7 +157,7 @@ describe('MascHandler', () => { MascHandler.setSequenceState(entry, 3); entry.setState(MASC_ACTIVE_STATE_KEY, 'true'); - handler.handleSelection(entry, handler.getChoices(entry, context())[0], context()); + handler.handleSelection(entry, handler.getChoices(entry, queryContext())[0], commandContext); expect(MascHandler.getSequenceState(entry)).toBe(1); expect(handler.isActive(entry)).toBeFalse(); @@ -147,20 +167,20 @@ describe('MascHandler', () => { const entry = mascEntry(['F_MASC', 'F_JET_BOOSTER'], false); expect(MascHandler.canUseHandler(entry)).toBeFalse(); - expect(handler.getChoices(entry, context())).toEqual([]); + expect(handler.getChoices(entry, queryContext())).toEqual([]); }); it('allows Jet Booster choices when the unit is airborne', () => { const entry = mascEntry(['F_MASC', 'F_JET_BOOSTER'], true); expect(MascHandler.canUseHandler(entry)).toBeTrue(); - expect(handler.getChoices(entry, context()).length).toBe(6); + expect(handler.getChoices(entry, queryContext()).length).toBe(6); }); it('ignores Jet Booster selections when the unit is not airborne', () => { const entry = mascEntry(['F_MASC', 'F_JET_BOOSTER'], false); - handler.handleSelection(entry, { label: '3+', value: 0, displayType: 'toggle' }, context()); + handler.handleSelection(entry, { label: '3+', value: 0, displayType: 'toggle' }, commandContext); expect(MascHandler.getSequenceState(entry)).toBe(0); expect(handler.isActive(entry)).toBeFalse(); @@ -169,11 +189,11 @@ describe('MascHandler', () => { it('adds a run movement multiplier bonus while active', () => { const entry = mascEntry(); - expect(handler.getRunMovementMultiplierBonus(entry, entry.owner.turnState())).toBe(0); + expect(handler.getRunMovementMultiplierBonus(entry, entry.owner.turnState(), queryContext())).toBe(0); entry.setState(MASC_ACTIVE_STATE_KEY, 'true'); - expect(handler.getRunMovementMultiplierBonus(entry, entry.owner.turnState())).toBe(0.5); + expect(handler.getRunMovementMultiplierBonus(entry, entry.owner.turnState(), queryContext())).toBe(0.5); }); it('adds Jet Booster run movement bonus only while airborne', () => { @@ -182,8 +202,8 @@ describe('MascHandler', () => { groundedEntry.setState(MASC_ACTIVE_STATE_KEY, 'true'); airborneEntry.setState(MASC_ACTIVE_STATE_KEY, 'true'); - expect(handler.getRunMovementMultiplierBonus(groundedEntry, groundedEntry.owner.turnState())).toBe(0); - expect(handler.getRunMovementMultiplierBonus(airborneEntry, airborneEntry.owner.turnState())).toBe(0.5); + expect(handler.getRunMovementMultiplierBonus(groundedEntry, groundedEntry.owner.turnState(), queryContext())).toBe(0); + expect(handler.getRunMovementMultiplierBonus(airborneEntry, airborneEntry.owner.turnState(), queryContext())).toBe(0.5); }); it('does not add a movement bonus while disabled', () => { @@ -191,7 +211,19 @@ describe('MascHandler', () => { entry.setState(MASC_ACTIVE_STATE_KEY, 'true'); entry.setState(ENTRY_DISABLED_STATE_KEY, 'true'); - expect(handler.getRunMovementMultiplierBonus(entry, entry.owner.turnState())).toBe(0); + expect(handler.getRunMovementMultiplierBonus(entry, entry.owner.turnState(), queryContext())).toBe(0); + }); + + it('uses canonical passive-effect permission instead of raw failure state', () => { + const entry = mascEntry(); + entry.setState(MASC_ACTIVE_STATE_KEY, 'true'); + entry.owner.canPerformEquipmentAction = () => { throw new Error('owner permission must not be queried'); }; + const canProvidePassiveEffect = jasmine.createSpy('canProvidePassiveEffect').and.returnValue(false); + const context = { ...queryContext(), canProvidePassiveEffect }; + + expect(entry.states.has(ENTRY_DISABLED_STATE_KEY)).toBeFalse(); + expect(handler.getRunMovementMultiplierBonus(entry, entry.owner.turnState(), context)).toBe(0); + expect(canProvidePassiveEffect).toHaveBeenCalledOnceWith(entry); }); it('resets active state at end turn without changing sequence state', () => { @@ -199,7 +231,7 @@ describe('MascHandler', () => { MascHandler.setSequenceState(entry, 2); entry.setState(MASC_ACTIVE_STATE_KEY, 'true'); - handler.onEndTurn(entry, context()); + handler.onEndTurn(entry, jasmine.createSpyObj('ToastService', ['showToast'])); expect(MascHandler.getSequenceState(entry)).toBe(2); expect(handler.isActive(entry)).toBeFalse(); @@ -209,14 +241,14 @@ describe('MascHandler', () => { it('reduces the sequence at end turn when it was not active', () => { const entry = mascEntry(); MascHandler.setSequenceState(entry, 2); - const handlerContext = context(); + const toastService = jasmine.createSpyObj('ToastService', ['showToast']); - handler.onEndTurn(entry, handlerContext); + handler.onEndTurn(entry, toastService); expect(MascHandler.getSequenceState(entry)).toBe(1); expect(handler.isActive(entry)).toBeFalse(); expect(entry.owner.setInventoryEntry).toHaveBeenCalledWith(entry); - expect(handlerContext.toastService.showToast).toHaveBeenCalledWith( + expect(toastService.showToast).toHaveBeenCalledWith( 'Atlas AS7-D (Natasha Kerensky): MASC sequence reduced to 1', 'info' ); @@ -225,7 +257,7 @@ describe('MascHandler', () => { it('does not reduce the sequence below zero at end turn', () => { const entry = mascEntry(); - handler.onEndTurn(entry, context()); + handler.onEndTurn(entry, jasmine.createSpyObj('ToastService', ['showToast'])); expect(MascHandler.getSequenceState(entry)).toBe(0); expect(entry.owner.setInventoryEntry).not.toHaveBeenCalled(); @@ -234,19 +266,24 @@ describe('MascHandler', () => { it('uses text labels normally and an icon in the turn summary', () => { const entry = mascEntry(); - expect(handler.getChoices(entry, context('inventory')).at(-1)).toEqual(jasmine.objectContaining({ + expect(handler.getChoices(entry, queryContext('inventory')).at(-1)).toEqual(jasmine.objectContaining({ label: 'Operational', value: 'escalating-failure-disabled', + stateEdit: 'disable', })); - expect(handler.getChoices(entry, context('turn-summary'))).toHaveSize(6); - expect(handler.getChoices(entry, context('turn-summary')).at(-1)).toEqual(jasmine.objectContaining({ + expect(handler.getChoices(entry, queryContext('turn-summary'))).toHaveSize(6); + expect(handler.getChoices(entry, queryContext('turn-summary')).at(-1)).toEqual(jasmine.objectContaining({ label: '✖', value: 'escalating-failure-disabled', })); entry.setState(ENTRY_DISABLED_STATE_KEY, 'true'); - expect(handler.getChoices(entry, context('turn-summary')).at(-1)?.colors).toEqual( + expect(handler.getChoices(entry, queryContext('inventory')).at(-1)).toEqual(jasmine.objectContaining({ + label: 'Malfunctioning', + stateEdit: 'enable', + })); + expect(handler.getChoices(entry, queryContext('turn-summary')).at(-1)?.colors).toEqual( jasmine.objectContaining({ selectedText: '#fff' }) ); }); @@ -256,8 +293,8 @@ describe('MascHandler', () => { MascHandler.setSequenceState(entry, 2); entry.setState(ENTRY_DISABLED_STATE_KEY, 'true'); - handler.handleSelection(entry, { label: '5+', value: 1, displayType: 'toggle' }, context()); - handler.onEndTurn(entry, context()); + handler.handleSelection(entry, { label: '5+', value: 1, displayType: 'toggle' }, commandContext); + handler.onEndTurn(entry, jasmine.createSpyObj('ToastService', ['showToast'])); expect(MascHandler.getSequenceState(entry)).toBe(2); expect(handler.isActive(entry)).toBeFalse(); @@ -265,16 +302,16 @@ describe('MascHandler', () => { it('disables and re-enables escalating failure equipment', () => { const entry = mascEntry(); - const handlerContext = context('inventory'); + const inventoryQueryContext = queryContext('inventory'); entry.setState(MASC_ACTIVE_STATE_KEY, 'true'); - const disableChoice = handler.getChoices(entry, handlerContext).at(-1)!; + const disableChoice = handler.getChoices(entry, inventoryQueryContext).at(-1)!; - handler.handleSelection(entry, disableChoice, handlerContext); + handler.handleSelection(entry, disableChoice, commandContext); expect(entry.states.get(ENTRY_DISABLED_STATE_KEY)).toBe('true'); expect(handler.isActive(entry)).toBeFalse(); - handler.handleSelection(entry, handler.getChoices(entry, handlerContext).at(-1)!, handlerContext); + handler.handleSelection(entry, handler.getChoices(entry, inventoryQueryContext).at(-1)!, commandContext); expect(entry.states.has(ENTRY_DISABLED_STATE_KEY)).toBeFalse(); }); @@ -282,7 +319,7 @@ describe('MascHandler', () => { it('ignores locked buttons', () => { const entry = mascEntry(); - handler.handleSelection(entry, handler.getChoices(entry, context())[2], context()); + handler.handleSelection(entry, handler.getChoices(entry, queryContext())[2], commandContext); expect(MascHandler.getSequenceState(entry)).toBe(0); }); @@ -290,7 +327,11 @@ describe('MascHandler', () => { it('disables every button when the equipment is unavailable', () => { const entry = mascEntry(); entry.setCommittedDestroyed(true); + const registry = new EquipmentInteractionRegistry(); + registry.register(handler); - expect(handler.getChoices(entry, context()).every(choice => choice.disabled)).toBeTrue(); + const rawChoices = handler.getChoices(entry, queryContext()); + expect(rawChoices.at(-1)).toEqual(jasmine.objectContaining({ stateEdit: 'disable', active: false })); + expect(registry.getChoices(entry, queryContext()).every(choice => choice.disabled)).toBeTrue(); }); -}); \ No newline at end of file +}); diff --git a/src/app/equipment-handlers/masc.handler.ts b/src/app/equipment-handlers/masc.handler.ts index d5588519d..eb1261161 100644 --- a/src/app/equipment-handlers/masc.handler.ts +++ b/src/app/equipment-handlers/masc.handler.ts @@ -5,7 +5,7 @@ import { EquipmentFlag } from '../models/equipment-flags.type'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { TurnState } from '../models/turn-state.model'; -import { isEquipmentDisabledByFailure } from './disabled-equipment.handler'; +import type { HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import { EscalatingFailureHandler } from './escalatingfailure.handler'; export const MASC_SEQUENCE_STATE_KEY = 'masc'; @@ -47,7 +47,15 @@ export class MascHandler extends EscalatingFailureHandler { return MascHandler.isActive(equipment); } - override getRunMovementMultiplierBonus(equipment: MountedEquipment, turnState: TurnState): number { - return this.isActive(equipment) && !isEquipmentDisabledByFailure(equipment) && canUseMascMovementBonus(equipment, turnState) ? 0.5 : 0; + override getRunMovementMultiplierBonus( + equipment: MountedEquipment, + turnState: TurnState, + context: HandlerQueryContext + ): number { + return this.isActive(equipment) + && context.canProvidePassiveEffect(equipment) + && canUseMascMovementBonus(equipment, turnState) + ? 0.5 + : 0; } -} \ No newline at end of file +} diff --git a/src/app/equipment-handlers/mml.handler.spec.ts b/src/app/equipment-handlers/mml.handler.spec.ts index ceaa7f9fc..de96869af 100644 --- a/src/app/equipment-handlers/mml.handler.spec.ts +++ b/src/app/equipment-handlers/mml.handler.spec.ts @@ -4,15 +4,14 @@ import { EquipmentFlag } from '../models/equipment-flags.type'; import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import { createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; import { MmlHandler } from './mml.handler'; function owner() { return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: createTestEquipmentRules(), } as never; } @@ -46,7 +45,7 @@ function ammo(id: string, name: string, flags: EquipmentFlag[] = []): AmmoEquipm describe('MmlHandler', () => { const handler = new MmlHandler(); - const context = {} as HandlerContext; + const context = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); it('does not duplicate SVG-owned mode picker choices', () => { expect(handler.getChoices(weapon(), context)).toEqual([]); @@ -94,4 +93,4 @@ describe('MmlHandler', () => { expect(handler.matchesInventoryAmmo(mml, ammo('unknown', 'MML 9 Ammo'), 'LRM', context)).toBeFalse(); expect(handler.matchesInventoryAmmo(mml, wrongRack, 'LRM', context)).toBeFalse(); }); -}); \ No newline at end of file +}); diff --git a/src/app/equipment-handlers/mml.handler.ts b/src/app/equipment-handlers/mml.handler.ts index 3fb5a5852..e7254d388 100644 --- a/src/app/equipment-handlers/mml.handler.ts +++ b/src/app/equipment-handlers/mml.handler.ts @@ -5,7 +5,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; import type { MountedEquipment } from '../models/mounted-equipment.model'; -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { resolveAmmoWeaponProfile } from '../models/ammo-weapon-profile.model'; @@ -17,15 +17,15 @@ export class MmlHandler extends EquipmentInteractionHandler { return equipment.equipment instanceof WeaponEquipment && equipment.equipment.ammoType === 'MML'; } - getChoices(_equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(_equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { return []; } - handleSelection(_equipment: MountedEquipment, _choice: PickerChoice, _context: HandlerContext): boolean { + handleSelection(_equipment: MountedEquipment, _choice: PickerChoice, _context: HandlerCommandContext): boolean { return true; } - override matchesInventoryAmmo(equipment: MountedEquipment, ammo: AmmoEquipment, mode: string | null, _context: HandlerContext): boolean | null { + override matchesInventoryAmmo(equipment: MountedEquipment, ammo: AmmoEquipment, mode: string | null, _context: HandlerQueryContext): boolean | null { if (!(equipment.equipment instanceof WeaponEquipment) || equipment.equipment.ammoType !== 'MML') return null; if (ammo.ammoType !== 'MML') return false; if (equipment.equipment.rackSize > 0 && ammo.rackSize !== equipment.equipment.rackSize) return false; diff --git a/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts b/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts index 44410b567..ac7e2ecd9 100644 --- a/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts +++ b/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts @@ -4,11 +4,17 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; -import { EquipmentRegistry } from '../models/equipment-lookup'; +import { EMPTY_EQUIPMENT_REGISTRY, EquipmentRegistry } from '../models/equipment-lookup'; import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import { EquipmentInteractionRegistry, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import type { CriticalSlot } from '../models/force-serialization'; +import type { DialogsService } from '../services/dialogs.service'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionRegistry, +} from '../services/equipment-interaction-registry.service'; +import type { ToastService } from '../services/toast.service'; import { resolveInventoryControlDamageText } from '../utils/inventory-control-damage.util'; import { PPC_CAPACITOR_CHARGING_STATE, @@ -21,11 +27,9 @@ import { function setup(destroyed = false, compatible = true) { const owner = { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: createTestEquipmentRules({ - getEquipmentStatus: (entry: MountedEquipment) => ( - entry.committedDestroyed() ? 'destroyed' : 'available' - ) - }) + getEquipmentStatus: (entry: MountedEquipment) => entry.committedDestroyed() ? 'destroyed' : 'available', + isEquipmentOperational: (entry: MountedEquipment) => !entry.committedDestroyed(), + canPerformEquipmentAction: (entry: MountedEquipment) => !entry.committedDestroyed(), } as unknown as CBTForceUnit; const capacitor = new MountedEquipment({ owner, @@ -58,10 +62,41 @@ function setup(destroyed = false, compatible = true) { return { owner, weapon, capacitor }; } -const context = { - toastService: { showToast: jasmine.createSpy('showToast') } -} as unknown as HandlerContext; +function setupWithCriticalSlots() { + const fixture = setup(); + const weaponSlots: CriticalSlot[] = [ + { id: 'Light PPC@RA#1', name: 'Light PPC', loc: 'RA', slot: 1 }, + { id: 'Light PPC@RA#2', name: 'Light PPC', loc: 'RA', slot: 2 }, + ]; + const capacitorSlots: CriticalSlot[] = [ + { id: 'PPC Capacitor@RA#3', name: 'PPC Capacitor', loc: 'RA', slot: 3 }, + { id: 'PPC Capacitor@RA#4', name: 'PPC Capacitor', loc: 'RA', slot: 4, armored: true }, + ]; + const unrelatedSlot: CriticalSlot = { + id: 'Other Light PPC@LA#1', + name: 'Light PPC', + loc: 'LA', + slot: 1, + }; + const currentSlots = [...weaponSlots, ...capacitorSlots, unrelatedSlot]; + fixture.weapon.critSlots = weaponSlots.map(slot => ({ ...slot })); + fixture.capacitor.critSlots = capacitorSlots.map(slot => ({ ...slot })); + Object.assign(fixture.owner, { + getCritSlots: () => currentSlots, + findCurrentCriticalSlot: (snapshot: CriticalSlot) => currentSlots.find(slot => + slot.loc === snapshot.loc && slot.slot === snapshot.slot) ?? null, + setCritSlots: jasmine.createSpy('setCritSlots'), + }); + return { ...fixture, weaponSlots, capacitorSlots, unrelatedSlot }; +} +const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); +const toastService = jasmine.createSpyObj('ToastService', ['showToast']); +const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + toastService, + jasmine.createSpyObj('DialogsService', ['createDialog']), +); describe('PpcCapacitorHandler', () => { const handler = new PpcCapacitorHandler(); @@ -75,7 +110,7 @@ describe('PpcCapacitorHandler', () => { equipmentCatalog: new EquipmentRegistry({}), }, { applyDamageEffects: (entry, value, damageContext) => - handler.applyInventoryControlDamageEffects(entry, value, damageContext, context) + handler.applyInventoryControlDamageEffects(entry, value, damageContext, queryContext) }); expect(damage).toBe('10 [DE]'); @@ -89,7 +124,7 @@ describe('PpcCapacitorHandler', () => { equipmentCatalog: new EquipmentRegistry({}), }, { applyDamageEffects: (entry, value, damageContext) => - handler.applyInventoryControlDamageEffects(entry, value, damageContext, context) + handler.applyInventoryControlDamageEffects(entry, value, damageContext, queryContext) })).toBe('5 [DE]'); const unavailable = setup(true); @@ -100,7 +135,7 @@ describe('PpcCapacitorHandler', () => { equipmentCatalog: new EquipmentRegistry({}), }, { applyDamageEffects: (entry, value, damageContext) => - handler.applyInventoryControlDamageEffects(entry, value, damageContext, context) + handler.applyInventoryControlDamageEffects(entry, value, damageContext, queryContext) })).toBe('5 [DE]'); }); @@ -114,7 +149,7 @@ describe('PpcCapacitorHandler', () => { equipmentCatalog: new EquipmentRegistry({}), }, { applyDamageEffects: (entry, value, damageContext) => - handler.applyInventoryControlDamageEffects(entry, value, damageContext, context) + handler.applyInventoryControlDamageEffects(entry, value, damageContext, queryContext) })).toBe('5 [DE]'); }); @@ -125,24 +160,42 @@ describe('PpcCapacitorHandler', () => { const charged = setup(); charged.capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); - expect(Array.from(registry.applyWeaponTypes(charged.weapon, baseTypes, context))).toEqual(['DE', 'X']); + expect(Array.from(registry.applyWeaponTypes(charged.weapon, baseTypes, queryContext))).toEqual(['DE', 'X']); expect(Array.from(baseTypes)).toEqual(['DE']); const discharged = setup(); - expect(registry.applyWeaponTypes(discharged.weapon, baseTypes, context)).toBe(baseTypes); + expect(registry.applyWeaponTypes(discharged.weapon, baseTypes, queryContext)).toBe(baseTypes); const unavailable = setup(true); unavailable.capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); - expect(registry.applyWeaponTypes(unavailable.weapon, baseTypes, context)).toBe(baseTypes); + expect(registry.applyWeaponTypes(unavailable.weapon, baseTypes, queryContext)).toBe(baseTypes); + }); + + it('uses the query context for pure capacitor projections without mutating state or base types', () => { + const { owner, weapon, capacitor } = setup(); + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); + owner.getEquipmentStatus = () => { throw new Error('owner status must not be queried'); }; + owner.isEquipmentOperational = () => { throw new Error('owner operational state must not be queried'); }; + const context = { ...queryContext, getStatus: () => 'available' as const }; + const baseTypes = new Set(['DE'] as const); + const weaponStates = new Map(weapon.states); + const capacitorStates = new Map(capacitor.states); + + const types = handler.applyInventoryControlWeaponTypes(weapon, baseTypes, context); + + expect(Array.from(types)).toEqual(['DE', 'X']); + expect(Array.from(baseTypes)).toEqual(['DE']); + expect(weapon.states).toEqual(weaponStates); + expect(capacitor.states).toEqual(capacitorStates); }); it('adds five firing heat and exposes replaceable passive heat while charged', () => { const { weapon, capacitor } = setup(); capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); - expect(handler.applyInventoryControlHeatEffects(weapon, { value: 5, weakened: false }, context)) + expect(handler.applyInventoryControlHeatEffects(weapon, { value: 5, weakened: false }, queryContext)) .toEqual({ value: 10, weakened: false }); - expect(handler.getInventoryHeatSources(weapon, {} as never)).toEqual([{ + expect(handler.getInventoryHeatSources(weapon, {} as never, queryContext)).toEqual([{ id: 'ppc-capacitor:ppc', label: 'PPC Capacitor', value: 5, @@ -153,39 +206,208 @@ describe('PpcCapacitorHandler', () => { it('charges for one turn, blocks firing, and becomes charged at end turn', () => { const { weapon, capacitor } = setup(); - handler.handleSelection(weapon, { value: PPC_CAPACITOR_CHARGING_STATE } as PickerChoice, context); + handler.handleSelection(weapon, { value: PPC_CAPACITOR_CHARGING_STATE } as PickerChoice, commandContext); expect(capacitor.states.get(PPC_CAPACITOR_STATE_KEY)).toBe(PPC_CAPACITOR_CHARGING_STATE); - expect(handler.isInventoryControlSelectable(weapon, context)).toBeFalse(); - expect(handler.getInventoryHeatSources(weapon, {} as never)[0]).toEqual(jasmine.objectContaining({ value: 5 })); - expect(handler.applyInventoryControlHeatEffects(weapon, { value: 5, weakened: false }, context)) + expect(handler.isInventoryControlSelectable(weapon, queryContext)).toBeFalse(); + expect(handler.getInventoryHeatSources(weapon, {} as never, queryContext)[0]).toEqual(jasmine.objectContaining({ value: 5 })); + expect(handler.applyInventoryControlHeatEffects(weapon, { value: 5, weakened: false }, queryContext)) .toEqual({ value: 5, weakened: false }); - handler.onEndTurn(weapon, context); + handler.onEndTurn(weapon); expect(capacitor.states.get(PPC_CAPACITOR_STATE_KEY)).toBe(PPC_CAPACITOR_CHARGED_STATE); - expect(handler.isInventoryControlSelectable(weapon, context)).toBeNull(); + expect(handler.isInventoryControlSelectable(weapon, queryContext)).toBeNull(); }); it('discharges and marks the capacitor fired after firing', () => { const { weapon, capacitor, owner } = setup(); capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); - handler.afterInventoryControlFire(weapon, context); + handler.afterInventoryControlFire(weapon); + + expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + expect(capacitor.states.get(PPC_CAPACITOR_FIRED_STATE_KEY)).toBe('1'); + expect(owner.setInventoryEntry).toHaveBeenCalledWith(capacitor); + }); + + it('discharges an unavailable capacitor after its linked PPC fires', () => { + const { weapon, capacitor, owner } = setup(true); + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); + + handler.afterInventoryControlFire(weapon); expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); expect(capacitor.states.get(PPC_CAPACITOR_FIRED_STATE_KEY)).toBe('1'); expect(owner.setInventoryEntry).toHaveBeenCalledWith(capacitor); }); + for (const state of [PPC_CAPACITOR_CHARGING_STATE, PPC_CAPACITOR_CHARGED_STATE] as const) { + for (const hitEntry of ['PPC', 'capacitor'] as const) { + it(`explodes both direct-inventory mounts when a ${state} ${hitEntry} hit is committed`, () => { + const { weapon, capacitor } = setup(); + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, state); + (hitEntry === 'PPC' ? weapon : capacitor).setPendingDestroyed(true); + + expect(weapon.committedDestroyed()).toBeFalse(); + expect(capacitor.committedDestroyed()).toBeFalse(); + + handler.beforeEquipmentStateCommit(weapon); + weapon.commitPendingDestroyed(); + capacitor.commitPendingDestroyed(); + + expect(weapon.committedDestroyed()).toBeTrue(); + expect(capacitor.committedDestroyed()).toBeTrue(); + expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + }); + } + } + + it('does not explode direct-inventory mounts before a charged hit is pending', () => { + const { weapon, capacitor, owner } = setup(); + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); + + handler.beforeEquipmentStateCommit(weapon); + + expect(weapon.hasPendingDestroyedChange()).toBeFalse(); + expect(capacitor.hasPendingDestroyedChange()).toBeFalse(); + expect(capacitor.states.get(PPC_CAPACITOR_STATE_KEY)).toBe(PPC_CAPACITOR_CHARGED_STATE); + expect(owner.setInventoryEntry).not.toHaveBeenCalled(); + }); + + it('commits an ordinary direct-inventory hit while the capacitor is discharged', () => { + const { weapon, capacitor } = setup(); + weapon.setPendingDestroyed(true); + + handler.beforeEquipmentStateCommit(weapon); + weapon.commitPendingDestroyed(); + capacitor.commitPendingDestroyed(); + + expect(weapon.committedDestroyed()).toBeTrue(); + expect(capacitor.committedDestroyed()).toBeFalse(); + expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + }); + + for (const state of [PPC_CAPACITOR_CHARGING_STATE, PPC_CAPACITOR_CHARGED_STATE] as const) { + for (const hitEntry of ['PPC', 'capacitor'] as const) { + it(`destroys every linked Mek critical slot when a ${state} ${hitEntry} slot hit is committed`, () => { + const { weapon, capacitor, owner, weaponSlots, capacitorSlots, unrelatedSlot } = setupWithCriticalSlots(); + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, state); + const hitSlots = hitEntry === 'PPC' ? weaponSlots : capacitorSlots; + hitSlots[0].hits = 1; + hitSlots[0].destroying = 10; + + handler.beforeEquipmentStateCommit(weapon); + + const explosionSlots = [...weaponSlots, ...capacitorSlots]; + expect(explosionSlots.every(slot => !!slot.destroying)).toBeTrue(); + expect(new Set(explosionSlots.map(slot => slot.destroying)).size).toBe(1); + expect(capacitorSlots[1].hits).toBe(2); + expect(unrelatedSlot.destroying).toBeUndefined(); + expect(weapon.hasPendingDestroyedChange()).toBeFalse(); + expect(capacitor.hasPendingDestroyedChange()).toBeFalse(); + expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + expect(owner.setCritSlots).toHaveBeenCalledTimes(1); + }); + } + } + + it('does not retrigger an explosion for an already committed critical hit', () => { + const committed = setupWithCriticalSlots(); + committed.capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); + committed.weaponSlots[0].hits = 1; + committed.weaponSlots[0].destroying = 10; + committed.weaponSlots[0].destroyed = 10; + + handler.beforeEquipmentStateCommit(committed.weapon); + + expect(committed.weaponSlots[1].destroying).toBeUndefined(); + expect(committed.capacitorSlots.every(slot => slot.destroying === undefined)).toBeTrue(); + expect(committed.owner.setCritSlots).not.toHaveBeenCalled(); + }); + + it('does not treat location-derived critical destruction as a PPC critical hit', () => { + const { weapon, capacitor, owner, weaponSlots, capacitorSlots } = setupWithCriticalSlots(); + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); + weaponSlots[0].destroying = 10; + + handler.beforeEquipmentStateCommit(weapon); + + expect(weaponSlots[0].destroying).toBe(10); + expect(weaponSlots[1].destroying).toBeUndefined(); + expect(capacitorSlots.every(slot => slot.destroying === undefined)).toBeTrue(); + expect(capacitor.states.get(PPC_CAPACITOR_STATE_KEY)).toBe(PPC_CAPACITOR_CHARGED_STATE); + expect(owner.setCritSlots).not.toHaveBeenCalled(); + + handler.onEndTurn(weapon); + + expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + }); + + it('does not explode a later PPC hit from a stale charge on an already destroyed capacitor', () => { + const { weapon, capacitor } = setup(); + capacitor.setCommittedDestroyed(true); + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); + weapon.setPendingDestroyed(true); + + handler.beforeEquipmentStateCommit(weapon); + weapon.commitPendingDestroyed(); + + expect(weapon.committedDestroyed()).toBeTrue(); + expect(capacitor.hasPendingDestroyedChange()).toBeFalse(); + expect(capacitor.states.get(PPC_CAPACITOR_STATE_KEY)).toBe(PPC_CAPACITOR_CHARGED_STATE); + }); + + it('still explodes a charged disabled pair that is not destroyed', () => { + const { owner, weapon, capacitor } = setup(); + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); + owner.getEquipmentStatus = () => 'disabled'; + weapon.setPendingDestroyed(true); + + handler.beforeEquipmentStateCommit(weapon); + weapon.commitPendingDestroyed(); + capacitor.commitPendingDestroyed(); + + expect(weapon.committedDestroyed()).toBeTrue(); + expect(capacitor.committedDestroyed()).toBeTrue(); + expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + }); + + it('explodes a charging capacitor when its hit is committed at end turn', () => { + const { weapon, capacitor } = setup(); + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGING_STATE); + weapon.setPendingDestroyed(true); + + handler.beforeEquipmentStateCommit(weapon); + handler.onEndTurn(weapon); + weapon.commitPendingDestroyed(); + capacitor.commitPendingDestroyed(); + + expect(weapon.committedDestroyed()).toBeTrue(); + expect(capacitor.committedDestroyed()).toBeTrue(); + expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + }); + + it('does not let an unavailable charging capacitor block its usable PPC', () => { + const { weapon, capacitor, owner } = setup(true); + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGING_STATE); + + expect(handler.isInventoryControlSelectable(weapon, queryContext)).toBeNull(); + + handler.onEndTurn(weapon); + + expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + expect(owner.setInventoryEntry).toHaveBeenCalledWith(capacitor); + }); + it('rejects charging after the linked PPC fired this turn', () => { const { weapon, capacitor } = setup(); capacitor.states.set(PPC_CAPACITOR_FIRED_STATE_KEY, '1'); - handler.handleSelection(weapon, { value: PPC_CAPACITOR_CHARGING_STATE } as PickerChoice, context); + handler.handleSelection(weapon, { value: PPC_CAPACITOR_CHARGING_STATE } as PickerChoice, commandContext); expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); - expect(context.toastService.showToast).toHaveBeenCalledWith( + expect(toastService.showToast).toHaveBeenCalledWith( 'A fired PPC cannot charge its capacitor this turn.', 'error' ); diff --git a/src/app/equipment-handlers/ppc-capacitor.handler.ts b/src/app/equipment-handlers/ppc-capacitor.handler.ts index ff19e7e9d..8d1797fa8 100644 --- a/src/app/equipment-handlers/ppc-capacitor.handler.ts +++ b/src/app/equipment-handlers/ppc-capacitor.handler.ts @@ -6,13 +6,14 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { TurnState } from '../models/turn-state.model'; import type { UnitHeatSource } from '../models/rules/unit-type-rules'; -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; 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/weapon-types.model'; import { EquipmentFlag } from '../models/equipment-flags.type'; +import type { CriticalSlot } from '../models/force-serialization'; export const PPC_CAPACITOR_STATE_KEY = 'ppc_capacitor_state'; export const PPC_CAPACITOR_CHARGING_STATE = 'charging'; @@ -32,9 +33,9 @@ export class PpcCapacitorHandler extends EquipmentInteractionHandler { return linkedPpcCapacitor(equipment) !== null; } - getChoices(equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, context: HandlerQueryContext): PickerChoice[] { const capacitor = linkedPpcCapacitor(equipment); - if (!capacitor || !isPpcCapacitorUsable(equipment, capacitor)) return []; + if (!capacitor || !isPpcCapacitorUsable(equipment, capacitor, context.getStatus)) return []; const state = ppcCapacitorState(capacitor); const active = state !== null; @@ -53,9 +54,9 @@ export class PpcCapacitorHandler extends EquipmentInteractionHandler { }]; } - handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerContext): boolean { + handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): boolean { const capacitor = linkedPpcCapacitor(equipment); - if (!capacitor || !isPpcCapacitorUsable(equipment, capacitor)) return true; + if (!capacitor || !isPpcCapacitorUsable(equipment, capacitor, getCanonicalOwnerStatus)) return true; const charging = choice.value === PPC_CAPACITOR_CHARGING_STATE; if (charging && capacitor.states.has(PPC_CAPACITOR_FIRED_STATE_KEY)) { @@ -69,9 +70,9 @@ export class PpcCapacitorHandler extends EquipmentInteractionHandler { return true; } - override afterInventoryControlFire(equipment: MountedEquipment, _context: HandlerContext): void { + override afterInventoryControlFire(equipment: MountedEquipment): void { const capacitor = linkedPpcCapacitor(equipment); - if (!capacitor || !isPpcCapacitorUsable(equipment, capacitor)) return; + if (!capacitor || !isCompatiblePpcCapacitorLink(equipment, capacitor)) return; const discharged = setPpcCapacitorState(capacitor, null); const markedFired = capacitor.setState(PPC_CAPACITOR_FIRED_STATE_KEY, '1'); const changed = discharged || markedFired; @@ -80,23 +81,32 @@ export class PpcCapacitorHandler extends EquipmentInteractionHandler { } } - override onEndTurn(equipment: MountedEquipment, _context: HandlerContext): void { + override onEndTurn(equipment: MountedEquipment): void { const capacitor = linkedPpcCapacitor(equipment); - if (!capacitor) return; + if (!capacitor || !isCompatiblePpcCapacitorLink(equipment, capacitor)) return; let changed = capacitor.deleteState(PPC_CAPACITOR_FIRED_STATE_KEY); - if (isPpcCapacitorUsable(equipment, capacitor) && ppcCapacitorState(capacitor) === PPC_CAPACITOR_CHARGING_STATE) { + const state = ppcCapacitorState(capacitor); + if ((hasPendingDestruction(equipment) + || hasPendingDestruction(capacitor) + || !isPpcCapacitorUsable(equipment, capacitor, getCanonicalOwnerStatus)) && state !== null) { + changed = setPpcCapacitorState(capacitor, null) || changed; + } else if (state === PPC_CAPACITOR_CHARGING_STATE) { changed = setPpcCapacitorState(capacitor, PPC_CAPACITOR_CHARGED_STATE) || changed; } if (changed) capacitor.owner.setInventoryEntry(capacitor); } - override isInventoryControlSelectable(equipment: MountedEquipment, _context: HandlerContext): boolean | null { + override isInventoryControlSelectable(equipment: MountedEquipment, context: HandlerQueryContext): boolean | null { const capacitor = linkedPpcCapacitor(equipment); - return capacitor && ppcCapacitorState(capacitor) === PPC_CAPACITOR_CHARGING_STATE ? false : null; + return capacitor + && isPpcCapacitorUsable(equipment, capacitor, context.getStatus) + && ppcCapacitorState(capacitor) === PPC_CAPACITOR_CHARGING_STATE + ? false + : null; } - override applyInventoryControlHeatEffects(equipment: MountedEquipment, effect: InventoryControlHeatEffect, _context: HandlerContext): InventoryControlHeatEffect { - return chargedLinkedPpcCapacitor(equipment) + override applyInventoryControlHeatEffects(equipment: MountedEquipment, effect: InventoryControlHeatEffect, context: HandlerQueryContext): InventoryControlHeatEffect { + return chargedLinkedPpcCapacitor(equipment, context.getStatus) ? { ...effect, value: effect.value + PPC_CAPACITOR_HEAT_BONUS } : effect; } @@ -105,9 +115,9 @@ export class PpcCapacitorHandler extends EquipmentInteractionHandler { equipment: MountedEquipment, damage: WeaponDamage, _damageContext: InventoryControlDamageContext, - _context: HandlerContext + context: HandlerQueryContext ): WeaponDamage { - return chargedLinkedPpcCapacitor(equipment) + return chargedLinkedPpcCapacitor(equipment, context.getStatus) ? { ...damage, values: damage.values.map(value => value + PPC_CAPACITOR_DAMAGE_BONUS), @@ -119,15 +129,57 @@ export class PpcCapacitorHandler extends EquipmentInteractionHandler { override applyInventoryControlWeaponTypes( equipment: MountedEquipment, types: ReadonlySet, - _context: HandlerContext + context: HandlerQueryContext ): ReadonlySet { - if (!chargedLinkedPpcCapacitor(equipment)) return types; + if (!chargedLinkedPpcCapacitor(equipment, context.getStatus)) return types; return new Set([...types, 'X']); } - override getInventoryHeatSources(equipment: MountedEquipment, _turnState: TurnState): UnitHeatSource[] { + override beforeEquipmentStateCommit(equipment: MountedEquipment): void { + const capacitor = linkedPpcCapacitor(equipment); + if (!capacitor + || !isCompatiblePpcCapacitorLink(equipment, capacitor) + || !isPpcCapacitorExplosive(capacitor) + || isPpcCapacitorPairDestroyed(equipment, capacitor) + || (!hasPendingDirectHit(equipment) && !hasPendingDirectHit(capacitor))) return; + + const criticalSlots = new Set([ + ...currentCriticalSlots(equipment), + ...currentCriticalSlots(capacitor), + ]); + const triggerTimestamps = [...criticalSlots] + .filter(isPendingCriticalHit) + .map(slot => slot.destroying!); + const timestamp = triggerTimestamps.length > 0 ? Math.min(...triggerTimestamps) : Date.now(); + let criticalSlotsChanged = false; + for (const slot of criticalSlots) { + if (slot.destroyed || slot.destroying) continue; + slot.hits = Math.max(slot.hits ?? 0, slot.armored ? 2 : 1); + slot.destroying = timestamp; + criticalSlotsChanged = true; + } + if (criticalSlotsChanged) { + equipment.owner.setCritSlots([...equipment.owner.getCritSlots()]); + } + + let inventoryChanged = setPpcCapacitorState(capacitor, null); + for (const entry of [equipment, capacitor]) { + if (currentCriticalSlots(entry).length === 0) { + inventoryChanged = entry.setPendingDestroyed(true) || inventoryChanged; + } + } + if (inventoryChanged) equipment.owner.setInventoryEntry(equipment); + } + + override getInventoryHeatSources( + equipment: MountedEquipment, + _turnState: TurnState, + context: HandlerQueryContext + ): UnitHeatSource[] { const capacitor = linkedPpcCapacitor(equipment); - if (!capacitor || !isPpcCapacitorUsable(equipment, capacitor) || ppcCapacitorState(capacitor) === null) return []; + if (!capacitor + || !isPpcCapacitorUsable(equipment, capacitor, context.getStatus) + || ppcCapacitorState(capacitor) === null) return []; return [{ id: `ppc-capacitor:${equipment.id}`, label: 'PPC Capacitor', @@ -148,29 +200,80 @@ function linkedPpcCapacitor(weapon: MountedEquipment): MountedEquipment | null { return weapon.linkedWith?.find(isPpcCapacitor) ?? null; } -function isPpcCapacitorUsable(weapon: MountedEquipment, capacitor: MountedEquipment): boolean { +type EquipmentStatusQuery = HandlerQueryContext['getStatus']; + +function isPpcCapacitorUsable( + weapon: MountedEquipment, + capacitor: MountedEquipment, + getStatus: EquipmentStatusQuery +): boolean { + return isCompatiblePpcCapacitorLink(weapon, capacitor) + && getStatus(weapon) === 'available' + && getStatus(capacitor) === 'available'; +} + +function getCanonicalOwnerStatus(equipment: MountedEquipment) { + return equipment.owner.getEquipmentStatus(equipment); +} + +function isPpcCapacitorPairDestroyed(weapon: MountedEquipment, capacitor: MountedEquipment): boolean { + return getCanonicalOwnerStatus(weapon) === 'destroyed' + || getCanonicalOwnerStatus(capacitor) === 'destroyed'; +} + +function isCompatiblePpcCapacitorLink(weapon: MountedEquipment, capacitor: MountedEquipment): boolean { return isPpcCapacitor(capacitor) && weapon.equipment != null - && isPpcCapacitorCompatibleWeapon(weapon.equipment) - && !weapon.isUnavailable() - && !capacitor.isUnavailable(); + && isPpcCapacitorCompatibleWeapon(weapon.equipment); } function isPpcCapacitorCharged(capacitor: MountedEquipment): boolean { return ppcCapacitorState(capacitor) === PPC_CAPACITOR_CHARGED_STATE; } +function isPpcCapacitorExplosive(capacitor: MountedEquipment): boolean { + const state = ppcCapacitorState(capacitor); + return state === PPC_CAPACITOR_CHARGING_STATE || state === PPC_CAPACITOR_CHARGED_STATE; +} + function ppcCapacitorState(capacitor: MountedEquipment): typeof PPC_CAPACITOR_CHARGING_STATE | typeof PPC_CAPACITOR_CHARGED_STATE | null { const state = capacitor.states.get(PPC_CAPACITOR_STATE_KEY); return state === PPC_CAPACITOR_CHARGING_STATE || state === PPC_CAPACITOR_CHARGED_STATE ? state : null; } -function chargedLinkedPpcCapacitor(weapon: MountedEquipment): MountedEquipment | null { +function chargedLinkedPpcCapacitor( + weapon: MountedEquipment, + getStatus: EquipmentStatusQuery +): MountedEquipment | null { const capacitor = linkedPpcCapacitor(weapon); - if (!capacitor || !isPpcCapacitorUsable(weapon, capacitor)) return null; + if (!capacitor || !isPpcCapacitorUsable(weapon, capacitor, getStatus)) return null; return isPpcCapacitorCharged(capacitor) ? capacitor : null; } +function currentCriticalSlots(equipment: MountedEquipment): CriticalSlot[] { + return equipment.critSlots?.flatMap(slot => equipment.owner.findCurrentCriticalSlot(slot) ?? []) ?? []; +} + +function hasPendingDirectHit(equipment: MountedEquipment): boolean { + const criticalSlots = currentCriticalSlots(equipment); + return criticalSlots.length > 0 + ? criticalSlots.some(isPendingCriticalHit) + : equipment.isDestroying(); +} + +function hasPendingDestruction(equipment: MountedEquipment): boolean { + const criticalSlots = currentCriticalSlots(equipment); + return criticalSlots.length > 0 + ? criticalSlots.some(slot => !!slot.destroying && !slot.destroyed) + : equipment.isDestroying(); +} + +function isPendingCriticalHit(slot: CriticalSlot): boolean { + return !!slot.destroying + && !slot.destroyed + && (slot.hits ?? 0) >= (slot.armored ? 2 : 1); +} + function setPpcCapacitorState( capacitor: MountedEquipment, state: typeof PPC_CAPACITOR_CHARGING_STATE | typeof PPC_CAPACITOR_CHARGED_STATE | null @@ -178,5 +281,3 @@ function setPpcCapacitorState( if (state !== null) return capacitor.setState(PPC_CAPACITOR_STATE_KEY, state); return capacitor.deleteState(PPC_CAPACITOR_STATE_KEY); } - - diff --git a/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts b/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts index e4d05d625..be3605b3b 100644 --- a/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts +++ b/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts @@ -3,20 +3,18 @@ // Author: Drake import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import { createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { RISC_LASER_PULSE_MODE, RISC_LASER_STANDARD_MODE, RiscLaserPulseModuleHandler } from './risc-laser-pulse-module.handler'; function owner() { return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: createTestEquipmentRules({ - getEquipmentStatus: (entry: MountedEquipment) => ( - entry.committedDestroyed() ? 'destroyed' : 'available' - ), - }), + getEquipmentStatus: (entry: MountedEquipment) => entry.committedDestroyed() ? 'destroyed' : 'available', + isEquipmentOperational: (entry: MountedEquipment) => !entry.committedDestroyed(), + canPerformEquipmentAction: (entry: MountedEquipment) => !entry.committedDestroyed(), } as never; } @@ -45,7 +43,7 @@ function module(destroyed = false): MountedEquipment { describe('RiscLaserPulseModuleHandler', () => { const handler = new RiscLaserPulseModuleHandler(); - const context = {} as HandlerContext; + const context = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); it('offers STD and PULSE modes from the linked laser row', () => { const linked = module(); const entry = laser(linked); @@ -66,14 +64,16 @@ describe('RiscLaserPulseModuleHandler', () => { expect(handler.applyInventoryControlHeatEffects(entry, { value: 3, weakened: false }, context)) .toEqual({ value: 5, weakened: false }); - expect(handler.getToHitAdjustments(linked, { parent: entry })).toEqual([{ + expect(handler.getToHitAdjustments(linked, { parent: entry }, context)).toEqual([{ kind: 'add', label: 'RISC Laser Pulse Module', modifier: -2 }]); entry.states.set(INVENTORY_CONTROL_MODE_STATE, RISC_LASER_STANDARD_MODE); expect(handler.applyInventoryControlHeatEffects(entry, { value: 3, weakened: false }, context)) .toEqual({ value: 3, weakened: false }); - expect(handler.getToHitAdjustments(linked, { parent: entry })).toEqual([{ kind: 'add', modifier: 0 }]); + expect(handler.getToHitAdjustments(linked, { parent: entry }, context)).toEqual([{ + kind: 'add', label: 'RISC Laser Pulse Module Inactive', modifier: 0 + }]); }); it('falls back to STD and allows aimed shots when the module is unavailable', () => { @@ -83,7 +83,9 @@ describe('RiscLaserPulseModuleHandler', () => { expect(handler.getChoices(entry, context)).toEqual([]); expect(handler.applyInventoryControlHeatEffects(entry, { value: 3, weakened: false }, context)) .toEqual({ value: 3, weakened: false }); - expect(handler.getToHitAdjustments(linked, { parent: entry })).toEqual([{ kind: 'add', modifier: 0 }]); + expect(handler.getToHitAdjustments(linked, { parent: entry }, context)).toEqual([{ + kind: 'add', label: 'RISC Laser Pulse Module Inactive', modifier: 0 + }]); expect(handler.canPerformAimedShot(entry, context)).toBeNull(); }); @@ -94,4 +96,4 @@ describe('RiscLaserPulseModuleHandler', () => { expect(handler.canPerformAimedShot(entry, context)).toBeFalse(); }); -}); \ No newline at end of file +}); diff --git a/src/app/equipment-handlers/risc-laser-pulse-module.handler.ts b/src/app/equipment-handlers/risc-laser-pulse-module.handler.ts index 2c82c0352..8344dc059 100644 --- a/src/app/equipment-handlers/risc-laser-pulse-module.handler.ts +++ b/src/app/equipment-handlers/risc-laser-pulse-module.handler.ts @@ -6,7 +6,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { WeaponEquipment } from '../models/equipment.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'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext, type ToHitAdjustmentContext } from '../services/equipment-interaction-registry.service'; import { INVENTORY_CONTROL_MODE_STATE, setInventoryControlMode } from '../utils/inventory-control.util'; import type { InventoryControlHeatEffect } from '../utils/inventory-control-heat.util'; @@ -21,9 +21,9 @@ export class RiscLaserPulseModuleHandler extends EquipmentInteractionHandler { return isRiscLaserPulseModule(equipment) || this.linkedRiscLaserPulseModule(equipment) !== null; } - getChoices(equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(equipment: MountedEquipment, context: HandlerQueryContext): PickerChoice[] { const module = this.linkedRiscLaserPulseModule(equipment); - if (!module || !this.isModuleUsable(equipment, module)) return []; + if (!module || !this.isModuleUsable(equipment, module, context)) return []; return [{ label: 'Mode', @@ -33,39 +33,42 @@ export class RiscLaserPulseModuleHandler extends EquipmentInteractionHandler { { label: 'STD', value: RISC_LASER_STANDARD_MODE }, { label: 'PULSE', value: RISC_LASER_PULSE_MODE } ], - disabled: equipment.isUnavailable(), keepOpen: true }]; } - handleSelection(equipment: MountedEquipment, choice: PickerChoice, _context: HandlerContext): boolean { + handleSelection(equipment: MountedEquipment, choice: PickerChoice, _context: HandlerCommandContext): boolean { setInventoryControlMode(equipment, String(choice.value)); return true; } - override applyInventoryControlHeatEffects(equipment: MountedEquipment, effect: InventoryControlHeatEffect, _context: HandlerContext): InventoryControlHeatEffect { + override applyInventoryControlHeatEffects(equipment: MountedEquipment, effect: InventoryControlHeatEffect, context: HandlerQueryContext): InventoryControlHeatEffect { const module = this.linkedRiscLaserPulseModule(equipment); - return module && this.isModuleUsable(equipment, module) && this.selectedMode(equipment) === RISC_LASER_PULSE_MODE + return module && this.isModuleUsable(equipment, module, context) && this.selectedMode(equipment) === RISC_LASER_PULSE_MODE ? { ...effect, value: effect.value + 2 } : effect; } - override getToHitAdjustments(equipment: MountedEquipment, context: ToHitAdjustmentContext): readonly ToHitAdjustment[] { - const parent = context.parent; + override getToHitAdjustments( + equipment: MountedEquipment, + adjustmentContext: ToHitAdjustmentContext, + context: HandlerQueryContext + ): readonly ToHitAdjustment[] { + const parent = adjustmentContext.parent; const label = equipment.equipment?.shortName ?? equipment.name; if (!parent) return isRiscLaserPulseModule(equipment) ? [{ kind: 'replace-base', value: -2, label }] : []; if (!isRiscLaserPulseModule(equipment) || !this.isLaserWithRiscModule(parent)) return []; - const active = this.isModuleUsable(parent, equipment) && this.selectedMode(parent) === RISC_LASER_PULSE_MODE; + const active = this.isModuleUsable(parent, equipment, context) && this.selectedMode(parent) === RISC_LASER_PULSE_MODE; return [{ kind: 'add', - ...(active && { label }), + label: active ? label : `${label} Inactive`, modifier: active ? -2 : 0 }]; } - override canPerformAimedShot(equipment: MountedEquipment, _context: HandlerContext): boolean | null { + override canPerformAimedShot(equipment: MountedEquipment, context: HandlerQueryContext): boolean | null { const module = this.linkedRiscLaserPulseModule(equipment); - if (!module || !this.isModuleUsable(equipment, module)) return null; + if (!module || !this.isModuleUsable(equipment, module, context)) return null; return this.selectedMode(equipment) === RISC_LASER_PULSE_MODE ? false : null; } @@ -77,8 +80,12 @@ export class RiscLaserPulseModuleHandler extends EquipmentInteractionHandler { return isLaserWithRiscModule(equipment); } - private isModuleUsable(laser: MountedEquipment, module: MountedEquipment): boolean { - return !laser.isUnavailable() && !module.isUnavailable(); + private isModuleUsable( + laser: MountedEquipment, + module: MountedEquipment, + context: HandlerQueryContext + ): boolean { + return context.getStatus(laser) === 'available' && context.getStatus(module) === 'available'; } private selectedMode(equipment: MountedEquipment): string { diff --git a/src/app/equipment-handlers/stealth.handler.spec.ts b/src/app/equipment-handlers/stealth.handler.spec.ts index 81005de95..38ee99891 100644 --- a/src/app/equipment-handlers/stealth.handler.spec.ts +++ b/src/app/equipment-handlers/stealth.handler.spec.ts @@ -4,15 +4,17 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import type { Equipment } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import { createHandlerCommandContext, createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import type { DialogsService } from '../services/dialogs.service'; +import type { ToastService } from '../services/toast.service'; import { StealthHandler } from './stealth.handler'; function equipment(flag: 'F_STEALTH' | 'F_CHAMELEON_SHIELD' | 'F_ECM'): MountedEquipment { const owner = { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: createTestEquipmentRules(), + isEquipmentOperational: () => true, } as never; return new MountedEquipment({ owner, @@ -25,9 +27,12 @@ function equipment(flag: 'F_STEALTH' | 'F_CHAMELEON_SHIELD' | 'F_ECM'): MountedE describe('StealthHandler', () => { const handler = new StealthHandler(); - const context = { - toastService: { showToast: jasmine.createSpy('showToast') }, - } as never as HandlerContext; + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast']), + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); it('applies to ordinary stealth and Chameleon LPS only', () => { expect(handler.applicableTo(equipment('F_STEALTH'))).toBeTrue(); @@ -38,13 +43,13 @@ describe('StealthHandler', () => { it('uses the same persisted toggle state for Chameleon LPS', () => { const chameleon = equipment('F_CHAMELEON_SHIELD'); - handler.handleSelection(chameleon, { value: 'enabled' } as PickerChoice, context); + handler.handleSelection(chameleon, { value: 'enabled' } as PickerChoice, commandContext); expect(chameleon.states.get('state')).toBe('enabled'); expect(chameleon.owner.setInventoryEntry).toHaveBeenCalledWith(chameleon); - expect(handler.getChoices(chameleon, context)[0]).toEqual(jasmine.objectContaining({ + expect(handler.getChoices(chameleon, queryContext)[0]).toEqual(jasmine.objectContaining({ active: true, value: 'disabled', })); }); -}); \ No newline at end of file +}); diff --git a/src/app/equipment-handlers/uacjamming.handler.spec.ts b/src/app/equipment-handlers/uacjamming.handler.spec.ts index e7861dbab..25fd8b22d 100644 --- a/src/app/equipment-handlers/uacjamming.handler.spec.ts +++ b/src/app/equipment-handlers/uacjamming.handler.spec.ts @@ -3,27 +3,31 @@ // Author: Drake import { WeaponEquipment, type AmmoType } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../models/rules/unit-type-rules'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import { createHandlerCommandContext, createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import type { DialogsService } from '../services/dialogs.service'; +import type { ToastService } from '../services/toast.service'; import { isEquipmentDisabledByFailure } from './disabled-equipment.handler'; import { UACJammingHandler } from './uacjamming.handler'; function owner(gameRules: CBTGameRules = CORE_2026_GAME_RULES) { + const getEquipmentStatus = (entry: MountedEquipment) => ( + entry.committedDestroyed() + ? 'destroyed' + : isEquipmentDisabledByFailure(entry) + ? 'disabled' + : 'available' + ); return { setInventoryEntry: jasmine.createSpy('setInventoryEntry'), gameRules, - rules: createTestEquipmentRules({ - getEquipmentStatus: (entry: MountedEquipment) => ( - entry.committedDestroyed() - ? 'destroyed' - : isEquipmentDisabledByFailure(entry) - ? 'disabled' - : 'available' - ) - }) + getEquipmentStatus, + isEquipmentOperational: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', + canPerformEquipmentAction: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', + canEditEquipmentState: () => true, } as never; } @@ -49,13 +53,12 @@ function entry(ammoType: AmmoType, states = new Map(), gameRules describe('UACJammingHandler', () => { const handler = new UACJammingHandler(); - const context = { - toastService: { showToast: jasmine.createSpy('showToast') } - } as never as HandlerContext; - - beforeEach(() => { - context.toastService.showToast = jasmine.createSpy('showToast'); - }); + const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); + const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast']), + jasmine.createSpyObj('DialogsService', ['createDialog']), + ); it('applies rotary autocannons and Tactical Warfare Ultra autocannons', () => { expect(handler.applicableTo(entry('AC_ROTARY'))).toBeTrue(); @@ -69,26 +72,26 @@ describe('UACJammingHandler', () => { it('toggles the shared disabled state with jam labels', () => { const mounted = entry('AC_ULTRA'); - expect(handler.getChoices(mounted, context)[0]).toEqual(jasmine.objectContaining({ + expect(handler.getChoices(mounted, queryContext)[0]).toEqual(jasmine.objectContaining({ label: 'Jam', shortLabel: 'Jam', active: false, value: ENTRY_DISABLED_STATE_VALUE })); - handler.handleSelection(mounted, handler.getChoices(mounted, context)[0], context); + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); expect(mounted.states.get(ENTRY_DISABLED_STATE_KEY)).toBe(ENTRY_DISABLED_STATE_VALUE); expect(mounted.states.has('state')).toBeFalse(); expect(mounted.owner.setInventoryEntry).toHaveBeenCalledWith(mounted); - expect(handler.getChoices(mounted, context)[0]).toEqual(jasmine.objectContaining({ + expect(handler.getChoices(mounted, queryContext)[0]).toEqual(jasmine.objectContaining({ label: 'Jammed', shortLabel: 'Unjam', active: true, })); - handler.handleSelection(mounted, handler.getChoices(mounted, context)[0], context); + handler.handleSelection(mounted, handler.getChoices(mounted, queryContext)[0], commandContext); expect(mounted.states.has(ENTRY_DISABLED_STATE_KEY)).toBeFalse(); }); -}); \ No newline at end of file +}); diff --git a/src/app/equipment-handlers/vibroblade.handler.spec.ts b/src/app/equipment-handlers/vibroblade.handler.spec.ts index 439662d5c..c05cdd657 100644 --- a/src/app/equipment-handlers/vibroblade.handler.spec.ts +++ b/src/app/equipment-handlers/vibroblade.handler.spec.ts @@ -3,11 +3,17 @@ // Author: Drake import { Equipment, type EquipmentRawData } from '../models/equipment.model'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; -import type { InventoryControlDisplayData } from '../utils/inventory-control.util'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionRegistry, +} from '../services/equipment-interaction-registry.service'; +import type { DialogsService } from '../services/dialogs.service'; +import type { ToastService } from '../services/toast.service'; +import type { InventoryControlDisplayData, InventoryControlDisplayEffectOptions } from '../utils/inventory-control.util'; import { getVibrobladeBaseDamage, VIBROBLADE_MODE_STATE, VIBROBLADE_OFF_MODE, VIBROBLADE_ON_MODE, VibrobladeHandler } from './vibroblade.handler'; const DISPLAY: InventoryControlDisplayData = { @@ -24,13 +30,12 @@ const DISPLAY: InventoryControlDisplayData = { function setup(size: 'SMALL' | 'MEDIUM' | 'LARGE' = 'SMALL', destroyed = false, tons = 50) { const owner = { + readOnly: () => false, getUnit: () => ({ tons }), setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - rules: createTestEquipmentRules({ - getEquipmentStatus: (entry: MountedEquipment) => ( - entry.committedDestroyed() ? 'destroyed' : 'available' - ), - }), + getEquipmentStatus: (entry: MountedEquipment) => entry.committedDestroyed() ? 'destroyed' : 'available', + isEquipmentOperational: (entry: MountedEquipment) => !entry.committedDestroyed(), + canPerformEquipmentAction: (entry: MountedEquipment) => !entry.committedDestroyed(), } as unknown as CBTForceUnit; const equipment = new Equipment({ id: `${size}Vibroblade`, @@ -49,10 +54,21 @@ function setup(size: 'SMALL' | 'MEDIUM' | 'LARGE' = 'SMALL', destroyed = false, return { owner, entry }; } -const context = {} as HandlerContext; +const queryContext = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); +const commandContext = createHandlerCommandContext( + EMPTY_EQUIPMENT_REGISTRY, + jasmine.createSpyObj('ToastService', ['showToast']), + jasmine.createSpyObj('DialogsService', ['createDialog']), +); +const displayOptions: InventoryControlDisplayEffectOptions = { + selectedRange: null, + hitModifierBreakdown: [], +}; describe('VibrobladeHandler', () => { const handler = new VibrobladeHandler(); + const registry = new EquipmentInteractionRegistry(); + registry.register(handler); it('applies only to clubs with a vibroblade size flag', () => { const vibroblade = setup().entry; @@ -71,23 +87,29 @@ describe('VibrobladeHandler', () => { it('defaults to OFF and persists ON/OFF selections', () => { const { owner, entry } = setup(); - expect(handler.getChoices(entry, context)[0]).toEqual(jasmine.objectContaining({ + expect(handler.getChoices(entry, queryContext)[0]).toEqual(jasmine.objectContaining({ label: 'Mode', value: VIBROBLADE_OFF_MODE, - disabled: false, })); + expect(registry.getChoices(entry, queryContext)[0].disabled).toBeFalse(); - expect(handler.handleSelection(entry, { value: VIBROBLADE_ON_MODE } as never, context)).toBeFalse(); + expect(handler.handleSelection(entry, { value: VIBROBLADE_ON_MODE } as never, commandContext)).toBeFalse(); expect(entry.states.get(VIBROBLADE_MODE_STATE)).toBe(VIBROBLADE_ON_MODE); expect(owner.setInventoryEntry).toHaveBeenCalledWith(entry); - handler.handleSelection(entry, { value: VIBROBLADE_OFF_MODE } as never, context); + handler.handleSelection(entry, { value: VIBROBLADE_OFF_MODE } as never, commandContext); expect(entry.states.get(VIBROBLADE_MODE_STATE)).toBe(VIBROBLADE_OFF_MODE); expect(owner.setInventoryEntry).toHaveBeenCalledTimes(2); }); it('disables mode selection when the vibroblade is unavailable', () => { - expect(handler.getChoices(setup('SMALL', true).entry, context)[0].disabled).toBeTrue(); + const { entry } = setup('SMALL', true); + + expect(handler.getChoices(entry, queryContext)[0]).toEqual(jasmine.objectContaining({ + label: 'Mode', + value: VIBROBLADE_OFF_MODE, + })); + expect(registry.getChoices(entry, queryContext)[0].disabled).toBeTrue(); }); it('applies the -2 vibroblade target-number modifier in both modes', () => { @@ -103,7 +125,7 @@ describe('VibrobladeHandler', () => { const { entry } = setup(size); entry.states.set(VIBROBLADE_MODE_STATE, VIBROBLADE_ON_MODE); - const display = handler.applyInventoryControlDisplayEffects(entry, DISPLAY, {} as never, context); + const display = handler.applyInventoryControlDisplayEffects(entry, DISPLAY, displayOptions, queryContext); expect(display.heat).withContext(size).toBe(`${heat}`); expect(display.damage).withContext(size).toBe(`${damage}`); expect(handler.getInventoryControlHeatEffect(entry)).withContext(size).toEqual({ @@ -138,13 +160,13 @@ describe('VibrobladeHandler', () => { const { entry } = setup('MEDIUM', false, 40); const baseEffect = { baseDamage: 10, ignoreMyomer: false }; - expect(handler.applyInventoryControlPhysicalDamageEffects(entry, baseEffect, context)).toEqual({ + expect(handler.applyInventoryControlPhysicalDamageEffects(entry, baseEffect, queryContext)).toEqual({ baseDamage: 5, ignoreMyomer: false, }); entry.states.set(VIBROBLADE_MODE_STATE, VIBROBLADE_ON_MODE); - expect(handler.applyInventoryControlPhysicalDamageEffects(entry, baseEffect, context)).toEqual({ + expect(handler.applyInventoryControlPhysicalDamageEffects(entry, baseEffect, queryContext)).toEqual({ baseDamage: 10, ignoreMyomer: true, }); @@ -152,7 +174,12 @@ describe('VibrobladeHandler', () => { it('shows potential heat but emits no heat source while OFF', () => { const off = setup().entry; - const display = handler.applyInventoryControlDisplayEffects(off, { ...DISPLAY, heat: '3', damage: '6 [12]' }, {} as never, context); + const display = handler.applyInventoryControlDisplayEffects( + off, + { ...DISPLAY, heat: '3', damage: '6 [12]' }, + displayOptions, + queryContext, + ); expect(display.heat).toBe('[3]'); expect(display.damage).toBe('6 [7]'); expect(handler.getInventoryControlHeatEffect(off)).toBeNull(); diff --git a/src/app/equipment-handlers/vibroblade.handler.ts b/src/app/equipment-handlers/vibroblade.handler.ts index 79fe680f6..6408da0db 100644 --- a/src/app/equipment-handlers/vibroblade.handler.ts +++ b/src/app/equipment-handlers/vibroblade.handler.ts @@ -5,7 +5,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { ToHitAdjustment } from '../models/rules/game-rules'; -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import type { InventoryControlDisplayData, InventoryControlDisplayEffectOptions } from '../utils/inventory-control.util'; import type { InventoryControlPhysicalDamageEffect } from '../utils/inventory-control-physical-damage.util'; import { getVibrobladeProfile } from '../models/rules/vibroblade-rules'; @@ -45,7 +45,7 @@ export class VibrobladeHandler extends EquipmentInteractionHandler { return getVibrobladeProfile(mounted.equipment) !== null; } - override getChoices(mounted: MountedEquipment, _context: HandlerContext): PickerChoice[] { + override getChoices(mounted: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { return [{ label: 'Mode', value: getVibrobladeMode(mounted), @@ -54,12 +54,11 @@ export class VibrobladeHandler extends EquipmentInteractionHandler { { label: VIBROBLADE_ON_MODE, value: VIBROBLADE_ON_MODE }, { label: VIBROBLADE_OFF_MODE, value: VIBROBLADE_OFF_MODE }, ], - disabled: mounted.isUnavailable(), keepOpen: true, }]; } - override handleSelection(mounted: MountedEquipment, choice: PickerChoice, _context: HandlerContext): boolean { + override handleSelection(mounted: MountedEquipment, choice: PickerChoice, _context: HandlerCommandContext): boolean { const mode = choice.value === VIBROBLADE_ON_MODE ? VIBROBLADE_ON_MODE : VIBROBLADE_OFF_MODE; if (mounted.setState(VIBROBLADE_MODE_STATE, mode)) { mounted.owner.setInventoryEntry(mounted); @@ -75,7 +74,7 @@ export class VibrobladeHandler extends EquipmentInteractionHandler { mounted: MountedEquipment, display: InventoryControlDisplayData, _options: InventoryControlDisplayEffectOptions, - _context: HandlerContext, + _context: HandlerQueryContext, ): InventoryControlDisplayData { const profile = getVibrobladeProfile(mounted.equipment); if (!profile) return display; @@ -92,7 +91,7 @@ export class VibrobladeHandler extends EquipmentInteractionHandler { override applyInventoryControlPhysicalDamageEffects( mounted: MountedEquipment, effect: InventoryControlPhysicalDamageEffect, - _context: HandlerContext, + _context: HandlerQueryContext, ): InventoryControlPhysicalDamageEffect { const profile = getVibrobladeProfile(mounted.equipment); const baseDamage = getVibrobladeBaseDamage(mounted); diff --git a/src/app/equipment-handlers/weapon-ammo.handler.ts b/src/app/equipment-handlers/weapon-ammo.handler.ts index 4b8127ace..a504f0aad 100644 --- a/src/app/equipment-handlers/weapon-ammo.handler.ts +++ b/src/app/equipment-handlers/weapon-ammo.handler.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { EquipmentInteractionHandler, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { createHandlerQueryContext, EquipmentInteractionHandler, type HandlerChoice, type HandlerCommandContext, type HandlerQueryContext } from '../services/equipment-interaction-registry.service'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { PickerChoice } from '../components/picker/picker.interface'; import { WeaponEquipment } from '../models/equipment.model'; @@ -19,11 +19,11 @@ export class WeaponAmmoHandler extends EquipmentInteractionHandler { && equipment.equipment.ammoType !== 'NA'; }; - getChoices(equipment: MountedEquipment, context: HandlerContext): PickerChoice[] { - const entries = getAmmoControlEntriesForWeapon(equipment, context); + getChoices(equipment: MountedEquipment, context: HandlerQueryContext): HandlerChoice[] { + const entries = getAmmoControlEntriesForWeapon(equipment, context.equipmentCatalog); if (entries.length === 0) return []; - if (entries.length === 1 && !equipment.owner.readOnly()) { + if (entries.length === 1 && !context.isReadOnly(equipment)) { const entry = entries[0]; const remaining = getAmmoEntryRemaining(entry); return [ @@ -37,13 +37,15 @@ export class WeaponAmmoHandler extends EquipmentInteractionHandler { { label: 'Ammo', value: 'weapon-ammo-dialog', - displayType: 'button' + displayType: 'button', + readOnlySafe: context.isReadOnly(equipment), } ]; } - async handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerContext): Promise { - const entries = getAmmoControlEntriesForWeapon(equipment, context); + async handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): Promise { + const equipmentCatalog = context.equipmentCatalog; + const entries = getAmmoControlEntriesForWeapon(equipment, equipmentCatalog); if (entries.length === 0) return false; if (choice.value === 'weapon-ammo-dialog') { @@ -52,14 +54,14 @@ export class WeaponAmmoHandler extends EquipmentInteractionHandler { unit: equipment.owner, readOnly: equipment.owner.readOnly(), context: { - ...context, registry: { getChoices: () => [], handleSelection: () => false, afterInventoryControlFire: () => undefined, - canPerformAimedShot: () => true, inventoryControlRules: () => ({}) - } + }, + queryContext: createHandlerQueryContext(equipmentCatalog), + commandContext: context, }, initialTab: 'ammo' } as EquipmentDialogData, @@ -82,4 +84,4 @@ export class WeaponAmmoHandler extends EquipmentInteractionHandler { return false; } -} \ No newline at end of file +} diff --git a/src/app/models/cbt-force-unit-c3.spec.ts b/src/app/models/cbt-force-unit-c3.spec.ts index 6475881a7..2a1fd11b2 100644 --- a/src/app/models/cbt-force-unit-c3.spec.ts +++ b/src/app/models/cbt-force-unit-c3.spec.ts @@ -9,7 +9,11 @@ import { MountedEquipment } from './mounted-equipment.model'; import type { SerializedC3NetworkGroup } from './force-serialization'; import type { InventoryControlRuntimeTarget } from './inventory-control-runtime-state.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES } from './rules/game-rules'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; +import { UnitTypeRulesBase } from './rules/unit-type-rules'; + +class C3BadgeRules extends UnitTypeRulesBase { + override evaluateDestroyed(): void { } +} const TARGET: InventoryControlRuntimeTarget = { id: 'A', @@ -39,6 +43,7 @@ function c3BadgeUnit( unavailable: Set, ): CBTForceUnit { const unit = Object.create(CBTForceUnit.prototype) as CBTForceUnit; + const rules = new C3BadgeRules(unit); const inventory = mounts.map(mount => new MountedEquipment({ owner: unit, id: mount.id, @@ -51,13 +56,17 @@ function c3BadgeUnit( destroyed: { value: false, configurable: true }, shutdown: { value: false, writable: true, configurable: true }, getUnit: { value: () => ({ comp: [] }), configurable: true }, - rules: { - value: createTestEquipmentRules(), + getInventory: { value: () => inventory, configurable: true }, + getEquipmentStatus: { + value: (entry: MountedEquipment) => unavailable.has(entry.id) ? 'destroyed' : 'available', configurable: true, }, - getInventory: { value: () => inventory, configurable: true }, - isEquipmentUnavailable: { - value: (entry: MountedEquipment) => unavailable.has(entry.id), + isEquipmentOperational: { + value: (entry: MountedEquipment) => !unavailable.has(entry.id), + configurable: true, + }, + rules: { + value: rules, configurable: true, }, getCondition: { value: () => false, configurable: true }, @@ -83,13 +92,7 @@ function unitContext(options: { operationalPins?: Record; }) { let context: CBTForceUnit; - const slaveMount = new MountedEquipment({ - owner: null!, - id: 'slave', - name: 'C3 Slave', - equipment: { flags: new Set([C3_FLAGS.C3S]) } as Equipment, - states: new Map(), - }); + let slaveMount!: MountedEquipment; const master = { id: 'master', getCondition: (condition: string) => condition === 'jammed' && options.masterJammed === true, @@ -131,7 +134,13 @@ function unitContext(options: { : { ...target, c3Distance: undefined } }; context = unit as unknown as CBTForceUnit; - slaveMount.owner = context; + slaveMount = new MountedEquipment({ + owner: context, + id: 'slave', + name: 'C3 Slave', + equipment: { flags: new Set([C3_FLAGS.C3S]) } as Equipment, + states: new Map(), + }); return context; } @@ -150,10 +159,10 @@ describe('CBTForceUnit C3 targeting resolution', () => { configurable: true, }); - expect(CBTForceUnit.prototype.isEquipmentUnavailable.call(unit, unit.getInventory()[0])).toBeFalse(); - expect(CBTForceUnit.prototype.isEquipmentUnavailable.call(unit, unit.getInventory()[1])).toBeFalse(); - expect(CBTForceUnit.prototype.isEquipmentActionUnavailable.call(unit, unit.getInventory()[0])).toBeTrue(); - expect(CBTForceUnit.prototype.isEquipmentActionUnavailable.call(unit, unit.getInventory()[1])).toBeTrue(); + expect(unit.isEquipmentOperational(unit.getInventory()[0])).toBeTrue(); + expect(unit.isEquipmentOperational(unit.getInventory()[1])).toBeTrue(); + expect(CBTForceUnit.prototype.canPerformEquipmentAction.call(unit, unit.getInventory()[0], 'configure-network')).toBeFalse(); + expect(CBTForceUnit.prototype.canPerformEquipmentAction.call(unit, unit.getInventory()[1], 'configure-network')).toBeFalse(); expect(unit.isC3ComponentOperational(0)).toBeFalse(); expect(unit.isC3ComponentOperational(1)).toBeFalse(); }); @@ -174,6 +183,23 @@ describe('CBTForceUnit C3 targeting resolution', () => { expect(chameleonUnit.isC3ComponentOperational(0)).toBeTrue(); }); + it('uses C3 endpoint availability as the configure-network action authority', () => { + const unit = c3BadgeUnit('stealth-unit', [ + { id: 'c3', flag: C3_FLAGS.C3M }, + { id: 'stealth', flag: 'F_STEALTH' }, + ], new Set()); + const [c3, stealth] = unit.getInventory(); + + expect(unit.canPerformEquipmentAction(c3, 'configure-network')).toBeTrue(); + expect(unit.canPerformEquipmentAction(stealth, 'configure-network')).toBeFalse(); + + stealth.states.set('state', 'enabled'); + + expect(unit.isEquipmentOperational(c3)).toBeTrue(); + expect(unit.isC3ComponentOperational(0)).toBeFalse(); + expect(unit.canPerformEquipmentAction(c3, 'configure-network')).toBeFalse(); + }); + it('does not disconnect C3 for an unavailable stealth system with stale active state', () => { const unit = c3BadgeUnit('damaged-stealth', [ { id: 'c3', flag: C3_FLAGS.C3M }, @@ -193,7 +219,7 @@ describe('CBTForceUnit C3 targeting resolution', () => { ] as unknown as MountedEquipment[]; const context = { getInventory: () => inventory, - isEquipmentUnavailable: (entry: MountedEquipment) => unavailable.has(entry.id), + isEquipmentOperational: (entry: MountedEquipment) => !unavailable.has(entry.id), getMountedEquipmentByFlag: CBTForceUnit.prototype.getMountedEquipmentByFlag, } as unknown as CBTForceUnit; @@ -395,7 +421,7 @@ describe('CBTForceUnit C3 targeting resolution', () => { destroyed: false, getUnit: () => ({ comp: [] }), getInventory: () => masterMounts, - isEquipmentUnavailable: () => false, + isEquipmentOperational: () => true, isC3ComponentOperational: CBTForceUnit.prototype.isC3ComponentOperational, getCondition: () => false, } as unknown as CBTForceUnit; @@ -412,7 +438,7 @@ describe('CBTForceUnit C3 targeting resolution', () => { destroyed: false, getUnit: () => ({ comp: [] }), getInventory: () => mounts, - isEquipmentUnavailable: (entry: MountedEquipment) => unavailable.has(entry.id), + isEquipmentOperational: (entry: MountedEquipment) => !unavailable.has(entry.id), isC3ComponentOperational: CBTForceUnit.prototype.isC3ComponentOperational, getC3NetworkRuntimeState: CBTForceUnit.prototype.getC3NetworkRuntimeState, getCondition: () => false, diff --git a/src/app/models/cbt-force-unit-state.model.ts b/src/app/models/cbt-force-unit-state.model.ts index c817aa78c..14d5866c2 100644 --- a/src/app/models/cbt-force-unit-state.model.ts +++ b/src/app/models/cbt-force-unit-state.model.ts @@ -118,6 +118,11 @@ export class CBTForceUnitState extends ForceUnitState { const inventory = this.inventory(); let updated = false; inventory.forEach(item => { + if (item.isRepairing() + && this.unit.getEquipmentInstallationLocationStatus(item) === 'destroyed') { + updated = item.setPendingDestroyed(undefined) || updated; + return; + } updated = item.commitPendingDestroyed() || updated; }); if (updated) { diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index f5dd27c78..ade0b1103 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -20,12 +20,12 @@ import { UnitSvgMekService } from '../services/unit-svg-mek.service'; import { UnitSvgAeroService } from '../services/unit-svg-aero.service'; import { createEmptyUnit } from '../testing/unit-test-helpers'; import type { Unit } from './units.model'; -import { EquipmentInteractionHandler, EquipmentInteractionRegistryService, type HandlerContext } from '../services/equipment-interaction-registry.service'; +import { EquipmentInteractionHandler, EquipmentInteractionRegistryService } from '../services/equipment-interaction-registry.service'; import { LaserInsulatorHandler } from '../equipment-handlers/laser-insulator.handler'; import { RISC_LASER_PULSE_MODE, RiscLaserPulseModuleHandler } from '../equipment-handlers/risc-laser-pulse-module.handler'; import { DialogsService } from '../services/dialogs.service'; import { ToastService } from '../services/toast.service'; -import { getInventoryControlGroups, INVENTORY_CONTROL_MODE_STATE, syncSvgMode } from '../utils/inventory-control.util'; +import { getInventoryControlAmmoProfileId, getInventoryControlAmmoSelectionOptions, getInventoryControlGroups, INVENTORY_CONTROL_MODE_STATE, syncSvgMode } from '../utils/inventory-control.util'; import { AtmHandler } from '../equipment-handlers/atm.handler'; import { MmlHandler } from '../equipment-handlers/mml.handler'; import { ATM_EXTENDED_RANGE_PROFILE, ATM_HIGH_EXPLOSIVE_PROFILE, ATM_STANDARD_PROFILE } from './ammo-weapon-profile.model'; @@ -34,8 +34,13 @@ import { EquipmentFlag } from './equipment-flags.type'; import { EquipmentRegistry } from './equipment-lookup'; import { OptionsService } from '../services/options.service'; import { formatPilotingDisplay } from './rules/unit-type-rules'; -import { createTestEquipmentState } from '../testing/unit-test-helpers'; import { registerAllHandlers } from '../equipment-handlers'; +import { + PPC_CAPACITOR_CHARGING_STATE, + PPC_CAPACITOR_CHARGED_STATE, + PPC_CAPACITOR_STATE_KEY, + PpcCapacitorHandler, +} from '../equipment-handlers/ppc-capacitor.handler'; function createEquipment(): EquipmentMap { const ultraAc20 = new WeaponEquipment({ @@ -320,6 +325,19 @@ function createVehicleSvg(): SVGSVGElement { `, 'image/svg+xml').documentElement as unknown as SVGSVGElement; } +function createKamisoriAInventorySvg(): SVGSVGElement { + return new DOMParser().parseFromString(` + + + + + TU + + + + `, 'image/svg+xml').documentElement as unknown as SVGSVGElement; +} + function createVariableDamageUnit(equipment: EquipmentMap): Unit { return createEmptyUnit({ name: 'Variable Damage Test Unit', @@ -623,8 +641,7 @@ class ExposedUnitSvgService extends UnitSvgService { const baseValue = typeof baseResolution.value === 'number' ? baseResolution.value : 0; const resolution = this.unit.gameRules.resolveToHit({ subject: entry, - stateModifier: hitModifier - baseValue, - stateModifierBreakdown: [{ + stateModifiers: [{ label: 'Test modifier', modifier: hitModifier - baseValue, ...(forceWeakened && { weakened: true }), @@ -692,6 +709,11 @@ class EndTurnTestHandler extends EquipmentInteractionHandler { readonly id = 'end-turn-test-handler'; override readonly flags: EquipmentFlag[] = ['F_TEST_ONLY']; calls = 0; + readonly receivedCurrentEntries: boolean[] = []; + + constructor(private readonly rebuildInventory = false) { + super(); + } override applicableTo(equipment: MountedEquipment): boolean { return equipment.equipment?.id === 'end-turn-test'; @@ -705,8 +727,12 @@ class EndTurnTestHandler extends EquipmentInteractionHandler { return false; } - override onEndTurn(_equipment: MountedEquipment, _context: HandlerContext): void { + override onEndTurn(equipment: MountedEquipment): void { this.calls++; + this.receivedCurrentEntries.push( + equipment.owner.getInventory().find(candidate => candidate.id === equipment.id) === equipment + ); + if (this.rebuildInventory) equipment.owner.setInventoryEntry(equipment); } } @@ -911,9 +937,13 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); weaponEntry.linkedWith = [ammoEntry]; forceUnit.setInventory([weaponEntry, ammoEntry], true); + const availabilitySpy = spyOn(forceUnit, 'isEquipmentOperational') + .and.throwError('selected profile must not inspect source availability'); expect(forceUnit.getInventoryControlSelectedAmmo(weaponEntry)).toBe(intrinsicAmmo); - expect(() => weaponEntry.owner.rules.getEquipmentToHit(weaponEntry)).not.toThrow(); + expect(availabilitySpy).not.toHaveBeenCalled(); + availabilitySpy.and.callThrough(); + expect(() => weaponEntry.owner.rules.getEquipmentToHitModifiers(weaponEntry)).not.toThrow(); }); it('clones virtual inventory rows from a computed without writing signals', () => { @@ -1018,6 +1048,31 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(handler.calls).toBe(1); }); + it('reacquires each current mount when an end-turn hook rebuilds inventory', () => { + const handler = new EndTurnTestHandler(true); + TestBed.inject(EquipmentInteractionRegistryService).getRegistry().register(handler); + const forceUnit = createForceUnit(); + const testWeapon = new WeaponEquipment({ + id: 'end-turn-test', + name: 'End Turn Test', + type: 'weapon', + flags: ['F_TEST_ONLY'], + weapon: { damage: 1 }, + }); + forceUnit.setInventory(['A', 'B'].map(id => new MountedWeapon({ + owner: forceUnit, + id: `end-turn-test@${id}#0`, + name: 'End Turn Test', + equipment: testWeapon, + intrinsicPhysicalAttack: true, + })), true); + + forceUnit.endTurn(); + + expect(handler.calls).toBe(2); + expect(handler.receivedCurrentEntries).toEqual([true, true]); + }); + it('applies heat, clears registered sources, and starts the next turn without a no-op resolution', () => { const forceUnit = createForceUnit(); forceUnit.turnState().moveMode.set('run'); @@ -2221,6 +2276,161 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(weaponEntry.pendingDestroyed()).toBeUndefined(); }); + function installChargedPpcPair( + forceUnit: CBTForceUnit, + criticalSlots = false, + ): { + weapon: MountedWeapon; + capacitor: MountedMisc; + weaponSlots: CriticalSlot[]; + capacitorSlots: CriticalSlot[]; + unrelatedSlot: CriticalSlot | null; + } { + const location = criticalSlots ? 'RA' : 'FR'; + const weaponId = `TestPPC@${location}#0`; + const capacitorId = `TestPPCCapacitor@${location}#1`; + const ppc = new WeaponEquipment({ + id: 'TestPPC', + name: 'Test PPC', + type: 'weapon', + flags: ['F_PPC', 'F_DIRECT_FIRE', 'F_ENERGY', 'F_PPC_CAPACITOR_COMPATIBLE'], + weapon: { damage: 10, heat: 10, ranges: [6, 12, 18, 24] }, + }); + const capacitorEquipment = new MiscEquipment({ + id: 'TestPPCCapacitor', + name: 'Test PPC Capacitor', + type: 'misc', + flags: ['F_WEAPON_ENHANCEMENT', 'F_PPC_CAPACITOR'], + }); + const weaponSlots: CriticalSlot[] = criticalSlots ? [ + { id: weaponId, name: ppc.name, loc: location, slot: 0, eq: ppc }, + { id: weaponId, name: ppc.name, loc: location, slot: 1, eq: ppc }, + ] : []; + const capacitorSlots: CriticalSlot[] = criticalSlots ? [ + { id: capacitorId, name: capacitorEquipment.name, loc: location, slot: 2, eq: capacitorEquipment }, + { id: capacitorId, name: capacitorEquipment.name, loc: location, slot: 3, eq: capacitorEquipment }, + ] : []; + const unrelatedSlot: CriticalSlot | null = criticalSlots + ? { id: 'Unrelated@LA#2', name: 'Unrelated', loc: 'LA', slot: 0 } + : null; + if (criticalSlots) { + forceUnit.setCritSlots([ + ...weaponSlots, + ...capacitorSlots, + unrelatedSlot!, + ], true); + } + + const capacitor = new MountedMisc({ + owner: forceUnit, + id: capacitorId, + name: capacitorEquipment.name, + equipment: capacitorEquipment, + locations: new Set([location]), + critSlots: capacitorSlots, + states: new Map([[PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE]]), + }); + const weapon = new MountedWeapon({ + owner: forceUnit, + id: weaponId, + name: ppc.name, + equipment: ppc, + locations: new Set([location]), + critSlots: weaponSlots, + }); + weapon.setLinkedEquipment([capacitor]); + forceUnit.setInventory([weapon, capacitor], true); + TestBed.inject(EquipmentInteractionRegistryService).getRegistry().register(new PpcCapacitorHandler()); + return { weapon, capacitor, weaponSlots, capacitorSlots, unrelatedSlot }; + } + + it('commits a charged PPC-capacitor explosion for direct inventory at phase end', () => { + const forceUnit = createForceUnit(createVehicleUnit(equipment)); + initialize(forceUnit); + const { weapon, capacitor } = installChargedPpcPair(forceUnit); + expect(forceUnit.applyEquipmentDamage(weapon)).toBeTrue(); + + forceUnit.endPhase(); + + const committedWeapon = forceUnit.getInventory().find(entry => entry.id === weapon.id)!; + const committedCapacitor = forceUnit.getInventory().find(entry => entry.id === capacitor.id)!; + expect(committedWeapon.committedDestroyed()).toBeTrue(); + expect(committedCapacitor.committedDestroyed()).toBeTrue(); + expect(committedWeapon.pendingDestroyed()).toBeUndefined(); + expect(committedCapacitor.pendingDestroyed()).toBeUndefined(); + expect(committedCapacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + }); + + it('commits a charging PPC-capacitor explosion before end-turn state advancement', () => { + const forceUnit = createForceUnit(createVehicleUnit(equipment)); + initialize(forceUnit); + const { weapon, capacitor } = installChargedPpcPair(forceUnit); + capacitor.setState(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGING_STATE); + forceUnit.setInventoryEntry(capacitor); + expect(forceUnit.applyEquipmentDamage(weapon)).toBeTrue(); + + forceUnit.endTurn(); + + const committedWeapon = forceUnit.getInventory().find(entry => entry.id === weapon.id)!; + const committedCapacitor = forceUnit.getInventory().find(entry => entry.id === capacitor.id)!; + expect(committedWeapon.committedDestroyed()).toBeTrue(); + expect(committedCapacitor.committedDestroyed()).toBeTrue(); + expect(committedCapacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + }); + + for (const consolidateImmediately of [false, true]) { + it(`commits a charged PPC-capacitor explosion across every Mek slot${consolidateImmediately ? ' immediately' : ' at phase end'}`, () => { + const forceUnit = createForceUnit(createMekUnit()); + const { weaponSlots, capacitorSlots, unrelatedSlot } = installChargedPpcPair(forceUnit, true); + const triggerSlot = consolidateImmediately ? weaponSlots[0] : capacitorSlots[0]; + + forceUnit.applyHitToCritSlot(triggerSlot, 1, consolidateImmediately); + if (!consolidateImmediately) forceUnit.endPhase(); + + const committedSlots = [...weaponSlots, ...capacitorSlots] + .map(slot => forceUnit.findCurrentCriticalSlot(slot)!); + const committedWeapon = forceUnit.getInventory().find(entry => entry.id === 'TestPPC@RA#0')!; + const committedCapacitor = forceUnit.getInventory().find(entry => entry.id === 'TestPPCCapacitor@RA#1')!; + expect(committedSlots.every(slot => !!slot.destroyed)).toBeTrue(); + expect(committedSlots.every(slot => + (slot.hits ?? 0) >= (slot.armored ? 2 : 1))).toBeTrue(); + expect(forceUnit.findCurrentCriticalSlot(unrelatedSlot!)?.destroyed).toBeUndefined(); + expect(forceUnit.findCurrentCriticalSlot(unrelatedSlot!)?.hits).toBeUndefined(); + expect(committedCapacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + expect(committedWeapon.linkedWith).toContain(committedCapacitor); + expect(committedCapacitor.parent).toBe(committedWeapon); + }); + } + + for (const capacitorState of [PPC_CAPACITOR_CHARGED_STATE, PPC_CAPACITOR_CHARGING_STATE] as const) { + it(`does not explode but clears a ${capacitorState} capacitor when its Mek location is destroyed`, () => { + const forceUnit = createForceUnit(createMekUnit()); + forceUnit.locations = { + armor: new Map(), + internal: new Map([['RA', { loc: 'RA', points: 1 }]]), + }; + forceUnit.setLocations({ RA: { internal: 0 } }, true); + const { capacitor, weaponSlots, capacitorSlots } = installChargedPpcPair(forceUnit, true); + forceUnit.isLoaded.set(true); + capacitor.setState(PPC_CAPACITOR_STATE_KEY, capacitorState); + forceUnit.setInventoryEntry(capacitor); + + forceUnit.addInternalHits('RA', 1); + + expect([...weaponSlots, ...capacitorSlots].every(slot => !!slot.destroying)).toBeTrue(); + expect([...weaponSlots, ...capacitorSlots].every(slot => (slot.hits ?? 0) === 0)).toBeTrue(); + + forceUnit.endTurn(); + + const committedSlots = [...weaponSlots, ...capacitorSlots] + .map(slot => forceUnit.findCurrentCriticalSlot(slot)!); + const committedCapacitor = forceUnit.getInventory().find(entry => entry.id === capacitor.id)!; + expect(committedSlots.every(slot => !!slot.destroyed)).toBeTrue(); + expect(committedSlots.every(slot => (slot.hits ?? 0) === 0)).toBeTrue(); + expect(committedCapacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + }); + } + it('uses the lowest gunnery skill among crew members', () => { const forceUnit = createForceUnit(createEmptyUnit({ name: 'BMTest_MEK-1', @@ -2518,6 +2728,20 @@ describe('CBTForceUnit direct inventory ammo bins', () => { forceUnit.setInventoryEntry(ammoEntries[0]); ammoEntries[1].consumed = 5; forceUnit.setInventoryEntry(ammoEntries[1]); + const weaponEntry = forceUnit.getInventory() + .find(entry => entry.equipment instanceof WeaponEquipment)!; + const precisionAmmo = equipment['Clan Ultra AC/20 Precision Ammo'] as AmmoEquipment; + const precisionProfileId = getInventoryControlAmmoProfileId(precisionAmmo); + const precisionOption = getInventoryControlAmmoSelectionOptions( + weaponEntry, + forceUnit.getEquipmentRegistry(), + (weapon, ammo, mode) => forceUnit.matchesInventoryControlAmmo(weapon, ammo, mode), + ).find(option => option.profileId === precisionProfileId); + expect(precisionOption).toBeDefined(); + forceUnit.setInventoryControlEntryAmmoSelection(weaponEntry.id, { + selectedProfileId: precisionProfileId, + preferredSourceOptionId: precisionOption!.id, + }); expect(ammoEntries.map(entry => entry.originalTotalAmmo)).toEqual([13, 12]); forceUnit.getUnit().comp = []; @@ -2529,6 +2753,11 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(repairedAmmoEntries.map(entry => entry.ammo)).toEqual([undefined, undefined]); expect(repairedAmmoEntries.map(entry => entry.totalAmmo)).toEqual([13, 12]); expect(repairedAmmoEntries.map(entry => entry.consumed)).toEqual([0, 0]); + expect(forceUnit.getInventoryControlEntryAmmoSelection(weaponEntry.id)).toEqual({ + selectedProfileId: precisionProfileId, + preferredSourceOptionId: null, + }); + expect(forceUnit.getInventoryControlSelectedAmmo(weaponEntry)).toBe(precisionAmmo); }); it('repairAll restores intrinsic ammo from its runtime mount baseline', () => { @@ -2950,8 +3179,8 @@ describe('CBTForceUnit direct inventory ammo bins', () => { forceUnit.setCondition('shutdown', true); TestBed.tick(); - expect(forceUnit.isEquipmentUnavailable(entry)).toBeFalse(); - expect(entry.isActionUnavailable()).toBeTrue(); + expect(forceUnit.isEquipmentOperational(entry)).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(entry, 'fire')).toBeFalse(); expect(entry.el!.classList.contains('disabledInventory')).toBeTrue(); expect(entry.el!.classList.contains('damagedInventory')).toBeFalse(); expect(entry.el!.classList.contains('selected')).toBeFalse(); @@ -2978,32 +3207,32 @@ describe('CBTForceUnit direct inventory ammo bins', () => { const talonDfa = intrinsicAttack('DFA [Talons]'); forceUnit.turnState().moveMode.set(null); // unknown case! - expect(charge.isActionUnavailable()).toBeFalse(); - expect(airMekRam.isActionUnavailable()).toBeFalse(); - expect(airMechRam.isActionUnavailable()).toBeFalse(); - expect(deathFromAbove.isActionUnavailable()).toBeFalse(); - expect(talonDfa.isActionUnavailable()).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(charge, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(airMekRam, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(airMechRam, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(deathFromAbove, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(talonDfa, 'physical-attack')).toBeTrue(); forceUnit.turnState().moveMode.set('stationary'); - expect(charge.isActionUnavailable()).toBeTrue(); - expect(airMekRam.isActionUnavailable()).toBeTrue(); - expect(airMechRam.isActionUnavailable()).toBeTrue(); - expect(deathFromAbove.isActionUnavailable()).toBeTrue(); - expect(talonDfa.isActionUnavailable()).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(charge, 'physical-attack')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(airMekRam, 'physical-attack')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(airMechRam, 'physical-attack')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(deathFromAbove, 'physical-attack')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(talonDfa, 'physical-attack')).toBeFalse(); forceUnit.turnState().moveMode.set('run'); - expect(charge.isActionUnavailable()).toBeFalse(); - expect(airMekRam.isActionUnavailable()).toBeFalse(); - expect(airMechRam.isActionUnavailable()).toBeFalse(); - expect(deathFromAbove.isActionUnavailable()).toBeTrue(); - expect(talonDfa.isActionUnavailable()).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(charge, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(airMekRam, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(airMechRam, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(deathFromAbove, 'physical-attack')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(talonDfa, 'physical-attack')).toBeFalse(); forceUnit.turnState().moveMode.set('jump'); - expect(charge.isActionUnavailable()).toBeTrue(); - expect(airMekRam.isActionUnavailable()).toBeFalse(); - expect(airMechRam.isActionUnavailable()).toBeFalse(); - expect(deathFromAbove.isActionUnavailable()).toBeFalse(); - expect(talonDfa.isActionUnavailable()).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(charge, 'physical-attack')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(airMekRam, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(airMechRam, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(deathFromAbove, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(talonDfa, 'physical-attack')).toBeTrue(); }); it('preserves action-unavailable SVG state when interactions synchronize modes', () => { @@ -3024,14 +3253,14 @@ describe('CBTForceUnit direct inventory ammo bins', () => { syncSvgMode(charge, null); - expect(charge.isDisabled()).toBeFalse(); - expect(charge.isActionUnavailable()).toBeTrue(); + expect(forceUnit.isEquipmentOperational(charge)).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(charge, 'physical-attack')).toBeFalse(); expect(el.classList.contains('disabledInventory')).toBeTrue(); forceUnit.turnState().moveMode.set(null); syncSvgMode(charge, null); - expect(charge.isActionUnavailable()).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(charge, 'physical-attack')).toBeTrue(); expect(el.classList.contains('disabledInventory')).toBeFalse(); }); @@ -3047,12 +3276,14 @@ describe('CBTForceUnit direct inventory ammo bins', () => { owner: forceUnit, id: 'hatchet@RA#0', name: 'Hatchet', + locations: new Set(['RA']), equipment: new Equipment({ id: 'hatchet', name: 'Hatchet', type: 'misc', flags: ['F_CLUB'] }), }); const rangedWeapon = new MountedEquipment({ owner: forceUnit, id: 'VariableDamageLaser@RA#0', name: 'Variable Damage Laser', + locations: new Set(['RA']), equipment: equipment['VariableDamageLaser'], }); forceUnit.turnState().moveMode.set('walk'); @@ -3061,16 +3292,312 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(intrinsicPunch.isPhysicalWeapon()).toBeTrue(); expect(hatchet.isPhysicalWeapon()).toBeTrue(); - expect(intrinsicPunch.isActionUnavailable()).toBeTrue(); - expect(hatchet.isActionUnavailable()).toBeTrue(); - expect(rangedWeapon.isActionUnavailable()).toBeFalse(); - expect(forceUnit.isEquipmentUnavailable(intrinsicPunch)).toBeFalse(); - expect(forceUnit.isEquipmentUnavailable(hatchet)).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(intrinsicPunch, 'physical-attack')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(hatchet, 'physical-attack')).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(rangedWeapon, 'fire')).toBeTrue(); + expect(forceUnit.isEquipmentOperational(intrinsicPunch)).toBeTrue(); + expect(forceUnit.isEquipmentOperational(hatchet)).toBeTrue(); forceUnit.setCondition('prone', false); - expect(intrinsicPunch.isActionUnavailable()).toBeFalse(); - expect(hatchet.isActionUnavailable()).toBeFalse(); + expect(forceUnit.canPerformEquipmentAction(intrinsicPunch, 'physical-attack')).toBeTrue(); + expect(forceUnit.canPerformEquipmentAction(hatchet, 'physical-attack')).toBeTrue(); + }); + + it('unions current critical and mount installation locations for whole-mount status', () => { + const forceUnit = createForceUnit(); + forceUnit.locations = { + armor: new Map([ + ['RA', { loc: 'RA', rear: false, points: 1 }], + ['LL', { loc: 'LL', rear: false, points: 1 }], + ]), + internal: new Map([ + ['RA', { loc: 'RA', points: 1 }], + ['LL', { loc: 'LL', points: 1 }], + ]), + }; + forceUnit.setLocations({ LL: { internal: 1 } }, true); + const critical: CriticalSlot = { + id: 'split-installation', + name: 'Variable Damage Laser', + loc: 'RA', + slot: 0, + eq: equipment['VariableDamageLaser'], + }; + forceUnit.writeCrits([critical]); + const entry = new MountedWeapon({ + owner: forceUnit, + id: 'split-installation', + name: 'Variable Damage Laser', + equipment: equipment['VariableDamageLaser'] as WeaponEquipment, + critSlots: [critical], + locations: new Set(['LL']), + }); + forceUnit.setInventory([entry], true); + + expect(forceUnit.getEquipmentStatusAtLocation(entry, 'RA')).toBe('available'); + expect(forceUnit.getEquipmentStatusAtLocation(entry, 'LL')).toBe('destroyed'); + expect(forceUnit.getEquipmentInstallationLocationStatus(entry)).toBe('destroyed'); + expect(forceUnit.getEquipmentStatus(entry)).toBe('destroyed'); + + entry.setCommittedDestroyed(true); + expect(forceUnit.canEditEquipmentState(entry, 'repair')).toBeFalse(); + expect(forceUnit.repairEquipment(entry)).toBeFalse(); + + entry.setPendingDestroyed(false); + expect(entry.isRepairing()).toBeTrue(); + expect(forceUnit.isEquipmentResolvedDestroyed(entry)).toBeTrue(); + expect(forceUnit.isEquipmentResolvedCommittedDestroyed(entry)).toBeTrue(); + + forceUnit.endPhase(); + expect(entry.isRepairing()).toBeFalse(); + expect(entry.committedDestroyed()).toBeTrue(); + expect(entry.pendingDestroyed()).toBeUndefined(); + }); + + it('cancels a pending repair when its installation location is destroyed before end-phase commit', () => { + const forceUnit = createForceUnit(); + forceUnit.locations = { + armor: new Map([['RA', { loc: 'RA', rear: false, points: 1 }]]), + internal: new Map([['RA', { loc: 'RA', points: 1 }]]), + }; + const entry = new MountedWeapon({ + owner: forceUnit, + id: 'repairing-location-loss', + name: 'Variable Damage Laser', + equipment: equipment['VariableDamageLaser'] as WeaponEquipment, + locations: new Set(['RA']), + destroyed: true, + }); + forceUnit.setInventory([entry], true); + + expect(forceUnit.getEquipmentInstallationLocationStatus(entry)).toBe('available'); + expect(forceUnit.repairEquipment(entry)).toBeTrue(); + expect(entry.isRepairing()).toBeTrue(); + + forceUnit.addInternalHits('RA', 1); + + expect(forceUnit.getEquipmentInstallationLocationStatus(entry)).toBe('available'); + expect(forceUnit.isInternalLocStructurallyDestroyed('RA')).toBeTrue(); + + forceUnit.endPhase(); + + expect(forceUnit.getEquipmentInstallationLocationStatus(entry)).toBe('destroyed'); + expect(entry.isRepairing()).toBeFalse(); + expect(entry.committedDestroyed()).toBeTrue(); + expect(entry.pendingDestroyed()).toBeUndefined(); + }); + + it('does not offer mount repair for destruction derived only from critical facts', () => { + const forceUnit = createForceUnit(); + forceUnit.locations = { + armor: new Map([['RA', { loc: 'RA', rear: false, points: 1 }]]), + internal: new Map([['RA', { loc: 'RA', points: 1 }]]), + }; + const critical: CriticalSlot = { + id: 'critical-only-destruction', + name: 'Variable Damage Laser', + loc: 'RA', + slot: 0, + destroyed: 1, + eq: equipment['VariableDamageLaser'], + }; + forceUnit.writeCrits([critical]); + const entry = new MountedWeapon({ + owner: forceUnit, + id: critical.id!, + name: critical.name!, + equipment: equipment['VariableDamageLaser'] as WeaponEquipment, + critSlots: [critical], + locations: new Set(['RA']), + }); + + expect(forceUnit.getEquipmentStatus(entry)).toBe('destroyed'); + expect(entry.committedDestroyed()).toBeFalse(); + expect(forceUnit.canEditEquipmentState(entry, 'repair')).toBeFalse(); + expect(forceUnit.repairEquipment(entry)).toBeFalse(); + expect(entry.pendingDestroyed()).toBeUndefined(); + }); + + it('restores the Kamisori A turret capacitor location from its direct-inventory ID', () => { + const lightPpc = new WeaponEquipment({ + id: 'Light PPC', + name: 'Light PPC', + type: 'weapon', + weapon: { ammoType: 'NA', damage: 5, ranges: [6, 12, 18, 24] }, + }); + const capacitorEquipment = new MiscEquipment({ + id: 'PPC Capacitor', + name: 'PPC Capacitor', + type: 'misc', + }); + equipment[lightPpc.internalName] = lightPpc; + equipment[capacitorEquipment.internalName] = capacitorEquipment; + const unit = createEmptyUnit({ + name: 'CVKamisoriLightTank_A', + chassis: 'Kamisori Light Tank', + model: 'A', + type: 'Tank', + subtype: 'Combat Vehicle Omni', + comp: [ + { id: 'Standard', q: 1, q2: 0, n: 'Standard Structure', t: 'S', p: -1, l: '', c: '1', os: 0 }, + { id: 'IS Heavy Ferro-Fibrous', q: 1, q2: 0, n: 'Heavy Ferro-Fibrous Armor', t: 'S', p: -1, l: '', c: '3', os: 0 }, + { id: lightPpc.internalName, q: 1, q2: 0, n: lightPpc.name, t: 'E', p: 5, l: 'TU', c: '1', os: 0, eq: lightPpc }, + { id: capacitorEquipment.internalName, q: 1, q2: 0, n: capacitorEquipment.name, t: 'C', p: 5, l: 'TU', c: '0', os: 0, eq: capacitorEquipment }, + { id: 'ISTargeting Computer', q: 1, q2: 0, n: 'Targeting Computer', t: 'C', p: 0, l: 'BD', c: '1', os: 0 }, + ], + }); + const original = createForceUnit(unit); + initialize(original, createKamisoriAInventorySvg()); + const originalCapacitor = original.getInventory().find(entry => entry.id === 'PPC Capacitor@TU#1')!; + + expect(originalCapacitor.parent?.id).toBe('Light PPC@TU#0'); + expect(original.getEquipmentInstallationLocationStatus(originalCapacitor)).toBe('available'); + original.setLocations({ TU: { internal: 1 } }, true); + + const restored = CBTForceUnit.deserialize( + original.serialize(), + new TestCBTForce('Restored Kamisori Force', dataService, unitInitializer, injector), + dataService, + unitInitializer, + injector, + ); + const warning = spyOn(console, 'warn'); + initialize(restored, createKamisoriAInventorySvg()); + const capacitor = restored.getInventory().find(entry => entry.id === originalCapacitor.id)!; + + expect(capacitor.committedDestroyed()).toBeFalse(); + expect(restored.getEquipmentLocationStatus('TU')).toBe('destroyed'); + expect(restored.getEquipmentInstallationLocationStatus(capacitor)).toBe('destroyed'); + expect(restored.getEquipmentStatus(capacitor)).toBe('destroyed'); + expect(restored.canEditEquipmentState(capacitor, 'repair')).toBeFalse(); + expect(restored.repairEquipment(capacitor)).toBeFalse(); + expect(warning.calls.allArgs().some(args => String(args[0]).includes(capacitor.id))).toBeFalse(); + }); + + it('maps a direct-inventory Battle Armor squad-support weapon to T1', () => { + const supportWeapon = new WeaponEquipment({ + id: 'ISBASquadSupportLaser', + name: 'BA Squad Support Laser', + type: 'weapon', + flags: ['F_BA_WEAPON', 'F_ENERGY'], + weapon: { ammoType: 'NA', damage: 1 }, + }); + const forceUnit = createForceUnit(createEmptyUnit({ + name: 'BA SSW Test', + type: 'Infantry', + subtype: 'Battle Armor', + squadSize: 2, + comp: [{ + id: supportWeapon.internalName, + n: supportWeapon.name, + t: 'E', + q: 1, + p: 0, + l: 'SSW', + eq: supportWeapon, + }], + })); + forceUnit.locations = { + armor: new Map([ + ['T1', { loc: 'T1', rear: false, points: 1 }], + ['T2', { loc: 'T2', rear: false, points: 1 }], + ]), + internal: new Map([ + ['T1', { loc: 'T1', points: 1 }], + ['T2', { loc: 'T2', points: 1 }], + ]), + }; + forceUnit.setLocations({ T1: { armor: 1 } }, true); + const entry = new MountedWeapon({ + owner: forceUnit, + id: `${supportWeapon.internalName}@SSW#0`, + name: supportWeapon.name, + equipment: supportWeapon, + locations: new Set(), + }); + + expect(forceUnit.getEquipmentLocationStatus('SSW')).toBe('destroyed'); + expect(forceUnit.getEquipmentStatus(entry)).toBe('destroyed'); + expect(forceUnit.canPerformEquipmentAction(entry, 'fire')).toBeFalse(); + }); + + it('preserves mount-global status and reports an unresolved direct-inventory installation once', () => { + const unknownEquipment = new Equipment({ id: 'UnknownInstallation', name: 'Unknown Installation', type: 'misc' }); + const forceUnit = createForceUnit(createEmptyUnit({ + name: 'Unknown Installation Test', + type: 'Tank', + subtype: 'Combat Vehicle', + comp: [{ id: unknownEquipment.internalName, n: unknownEquipment.name, t: 'X', q: 1, p: 0, l: '—', eq: unknownEquipment }], + })); + const entry = new MountedEquipment({ + owner: forceUnit, + id: `${unknownEquipment.internalName}@Unknown#0`, + name: unknownEquipment.name, + equipment: unknownEquipment, + }); + initialize(forceUnit); + const warning = spyOn(console, 'warn'); + + expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); + expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); + expect(warning).toHaveBeenCalledTimes(1); + expect(warning).toHaveBeenCalledWith(jasmine.stringContaining(entry.id)); + + entry.setCommittedDestroyed(true); + expect(forceUnit.getEquipmentStatus(entry)).toBe('destroyed'); + }); + + it('reports an unresolved loaded direct-inventory entry with a nonstandard ID once', () => { + const unknownEquipment = new Equipment({ id: 'MalformedInstallation', name: 'Malformed Installation', type: 'misc' }); + const forceUnit = createForceUnit(createEmptyUnit({ + name: 'Malformed Installation Test', + type: 'Tank', + subtype: 'Combat Vehicle', + comp: [{ id: unknownEquipment.internalName, n: unknownEquipment.name, t: 'X', q: 1, p: 0, l: '—', eq: unknownEquipment }], + })); + const entry = new MountedEquipment({ + owner: forceUnit, + id: 'nonstandard-direct-inventory-id', + name: unknownEquipment.name, + equipment: unknownEquipment, + }); + initialize(forceUnit); + const warning = spyOn(console, 'warn'); + + expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); + expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); + expect(warning).toHaveBeenCalledTimes(1); + expect(warning).toHaveBeenCalledWith(jasmine.stringContaining(entry.id)); + expect(warning.calls.mostRecent().args[0]).not.toContain('(component'); + }); + + it('applies damage through canonical resolved state and cancels a pending repair', () => { + const forceUnit = createForceUnit(); + const entry = new MountedWeapon({ + owner: forceUnit, + id: 'state-edit-laser', + name: 'Variable Damage Laser', + equipment: equipment['VariableDamageLaser'] as WeaponEquipment, + }); + forceUnit.setInventory([entry], true); + + expect(forceUnit.applyEquipmentDamage(entry)).toBeTrue(); + expect(entry.isDestroying()).toBeTrue(); + expect(forceUnit.applyEquipmentDamage(entry)).toBeFalse(); + expect(forceUnit.repairEquipment(entry)).toBeTrue(); + expect(entry.hasPendingDestroyedChange()).toBeFalse(); + + entry.setCommittedDestroyed(true); + entry.setPendingDestroyed(false); + expect(entry.isRepairing()).toBeTrue(); + expect(forceUnit.canEditEquipmentState(entry, 'apply-damage')).toBeTrue(); + + expect(forceUnit.applyEquipmentDamage(entry)).toBeTrue(); + expect(entry.isRepairing()).toBeFalse(); + expect(entry.committedDestroyed()).toBeTrue(); + expect(entry.hasPendingDestroyedChange()).toBeFalse(); + expect(forceUnit.canEditEquipmentState(entry, 'apply-damage')).toBeFalse(); }); it('wraps inventory damage across available SVG rows and clears stale rows', () => { @@ -3230,10 +3757,11 @@ describe('CBTForceUnit direct inventory ammo bins', () => { const module = laser.linkedWith![0]; const laserHitText = laser.el!.querySelector(':scope > .hitMod-text') as SVGTextElement; const moduleHitText = module.el!.querySelector(':scope > .hitMod-text') as SVGTextElement; - spyOn(forceUnit.rules, 'getEquipmentToHits').and.returnValue(new Map([ - [laser, createTestEquipmentState('available', []).toHit], - [module, createTestEquipmentState('available', [{ label: 'RISC Laser Pulse Module', modifier: 1 }]).toHit], - ])); + const toHitModifiers = new Map([ + [laser, []], + [module, [{ label: 'RISC Laser Pulse Module', modifier: 1 }]], + ]); + spyOn(forceUnit.rules, 'getEquipmentToHitModifiers').and.callFake(entry => toHitModifiers.get(entry) ?? []); const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgVehicleService(forceUnit, unitInitializer)); svgService.refreshInventory(); @@ -3481,7 +4009,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { svgService.refreshCrew(); expect(forceUnit.turnState().getAttackModifierBreakdown()).not.toContain(jasmine.objectContaining({ label: 'Prone' })); - expect(forceUnit.rules.getEquipmentToHit(ranged).modifiers) + expect(forceUnit.rules.getEquipmentToHitModifiers(ranged)) .toContain(jasmine.objectContaining({ label: 'Prone', modifier: 2 })); expect(svg.getElementById('gunnerySkill0')?.textContent).toBe('4'); }); @@ -3782,6 +4310,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { weapon: { ammoType: 'NA', ranges: [1, 2, 3, 4] }, }), el: entryElement, + locations: new Set(['FR']), }); svgService.renderHitModifier(createEntry(0), 0); @@ -3911,7 +4440,17 @@ describe('CBTForceUnit direct inventory ammo bins', () => { const weaponEntry = forceUnit.getInventory().find(entry => entry.equipment instanceof WeaponEquipment)!; forceUnit.createInventoryControlTarget(); forceUnit.setInventoryControlEntryTarget(weaponEntry, 'A'); - forceUnit.setInventoryControlEntryAmmoOption(weaponEntry.id, 'ammo-option'); + const [ammoOption] = getInventoryControlAmmoSelectionOptions( + weaponEntry, + forceUnit.getEquipmentRegistry(), + (weapon, ammo, mode) => forceUnit.matchesInventoryControlAmmo(weapon, ammo, mode), + ); + expect(ammoOption).toBeDefined(); + const ammoSelection = { + selectedProfileId: ammoOption.profileId, + preferredSourceOptionId: ammoOption.id, + }; + forceUnit.setInventoryControlEntryAmmoSelection(weaponEntry.id, ammoSelection); forceUnit.update({ id: forceUnit.id, @@ -3928,7 +4467,8 @@ describe('CBTForceUnit direct inventory ammo bins', () => { } as CBTSerializedUnit); expect(forceUnit.getInventoryControlEntryTargetId(weaponEntry.id)).toBe('A'); - expect(forceUnit.getInventoryControlSnapshot().entryStates.get(weaponEntry.id)?.ammoOption).toBe('ammo-option'); + expect(forceUnit.getInventoryControlSnapshot().entryStates.get(weaponEntry.id)?.ammoSelection) + .toEqual(ammoSelection); forceUnit.setInventory([]); forceUnit.update({ diff --git a/src/app/models/cbt-force-unit.model.ts b/src/app/models/cbt-force-unit.model.ts index 81328e366..8b0ae496c 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -6,7 +6,7 @@ import { computed, createEnvironmentInjector, effect, type EffectRef, Environmen import { DataService } from '../services/data.service'; import type { Unit } from "./units.model"; import type { UnitInitializerService } from '../services/unit-initializer.service'; -import { MountedAmmo, MountedEquipment } from './mounted-equipment.model'; +import { MountedAmmo, MountedEquipment, MountedWeapon } from './mounted-equipment.model'; import { type CriticalSlot, type HeatProfile, type LocationData, type ViewportTransform, CRIT_SLOT_SCHEMA, HEAT_SCHEMA, LOCATION_SCHEMA, INVENTORY_SCHEMA, C3_POSITION_SCHEMA, TURN_STATE_SCHEMA, type CBTSerializedState, type CBTSerializedUnit, type RuleCheckOutcome, type SerializedCrewMember, type SerializedRuleCheck, committedConditionData, conditionsForSerialization, conditionsHasActive, conditionsHasCommittedActive, conditionsMapFromSerialization, normalizeConditionData, normalizeConditionKey } from './force-serialization'; import { ForceUnit } from './force-unit.model'; import type { ConditionData } from './force-unit-state.model'; @@ -19,21 +19,22 @@ import { UnitSvgAeroService } from '../services/unit-svg-aero.service'; import { UnitSvgInfantryService } from '../services/unit-svg-infantry.service'; import { UnitSvgVehicleService } from '../services/unit-svg-vehicle.service'; import { BVCalculatorUtil } from '../utils/bv-calculator.util'; -import { AmmoEquipment, WeaponEquipment } from './equipment.model'; +import { AmmoEquipment } from './equipment.model'; import type { AmmoEquipment as AmmoEquipmentType } from './equipment.model'; import type { EquipmentFlag } from './equipment-flags.type'; +import type { WeaponType } from './weapon-types.model'; import { C3Capabilities, type C3Component, C3NetworkType, C3Role } from './c3-network.model'; import { isC3DisruptingStealthActive } from './stealth-equipment.model'; import { getMotiveModesOptionsByUnit, type MotiveModeOption } from './motiveModes.model'; import type { TurnState } from './turn-state.model'; import { Sanitizer } from '../utils/sanitizer.util'; import type { UnitTypeRules } from './rules/unit-type-rules'; -import { type InventoryControlRuntimeEntryState, type InventoryControlRuntimeRangeKey, type InventoryControlRuntimeSnapshot, type InventoryControlRuntimeTarget, type InventoryControlRuntimeTargetId } from './inventory-control-runtime-state.model'; +import { type InventoryControlRuntimeAmmoSelection, type InventoryControlRuntimeEntryState, type InventoryControlRuntimeRangeKey, type InventoryControlRuntimeSnapshot, type InventoryControlRuntimeTarget, type InventoryControlRuntimeTargetId } from './inventory-control-runtime-state.model'; import { CBTInventoryControlRuntime } from './cbt-inventory-control-runtime.model'; import { getMekLocationParent } from './entity/types'; -import { EquipmentInteractionRegistry, EquipmentInteractionRegistryService } from '../services/equipment-interaction-registry.service'; +import { createHandlerQueryContext, EquipmentInteractionRegistry, EquipmentInteractionRegistryService } from '../services/equipment-interaction-registry.service'; import type { UnitHeatSource } from './rules/unit-type-rules'; -import { getInventoryControlModeAmmoSummary, resolveInventoryControlSelectedAmmoOption, type InventoryControlDisplayData, type InventoryControlDisplayEffectOptions, type InventoryControlRules } from '../utils/inventory-control.util'; +import { resolveInventoryControlSelectedAmmoType, type InventoryControlDisplayData, type InventoryControlDisplayEffectOptions, type InventoryControlRules } from '../utils/inventory-control.util'; import { ToastService } from '../services/toast.service'; import { DialogsService } from '../services/dialogs.service'; import { getBattleArmorTrooperNumber, normalizeBattleArmorTrooperLocation } from './battle-armor-location.model'; @@ -41,6 +42,25 @@ import { CBTGameRulesService } from '../services/cbt-game-rules.service'; import type { C3DegradationSource, C3TargetingResolution, CBTGameRules } from './rules/game-rules'; import { OptionsService } from '../services/options.service'; import { resolveSelectedInventoryWeaponHeat } from '../utils/inventory-control-heat.util'; +import { parseInventoryComponentReference } from './inventory-component-reference.model'; +import type { InventoryControlPhysicalDamageEffect } from '../utils/inventory-control-physical-damage.util'; +import { + combineEquipmentStatuses, + type CriticalSlotStatusFacts, + type EquipmentStatus, + type EquipmentStatusFacts, +} from './equipment-status.model'; +import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from './rules/unit-type-rules'; + +export type EquipmentStatusSource = MountedEquipment | CriticalSlot; +export type EquipmentAction = + | 'fire' + | 'physical-attack' + | 'activate' + | 'change-mode' + | 'provide-passive-effect' + | 'configure-network'; +export type EquipmentStateEdit = 'enable' | 'disable' | 'repair' | 'apply-damage'; export class CBTForceUnit extends ForceUnit { override get force(): CBTForce { return super.force as CBTForce; } @@ -50,6 +70,7 @@ export class CBTForceUnit extends ForceUnit { private _svgService: UnitSvgService | null = null; private svgServiceInjector: EnvironmentInjector | null = null; private optionalRulesEffect: EffectRef | null = null; + private readonly unknownEquipmentInstallationLocationIds = new Set(); private _rules!: UnitTypeRules; readonly gameRules: CBTGameRules; viewState: ViewportTransform; @@ -133,18 +154,26 @@ export class CBTForceUnit extends ForceUnit { getEquipmentHeatSources(turnState: TurnState): UnitHeatSource[] { if (this.getCondition('shutdown')) return []; - return this.getEquipmentInteractionRegistry().getInventoryHeatSources(this.getInventory(), turnState); + return this.getEquipmentInteractionRegistry().getInventoryHeatSources( + this.getInventory(), + turnState, + this.getHandlerQueryContext(), + ); } getRunMovementMultiplierBonus(turnState: TurnState): number { if (this.getCondition('shutdown')) return 0; - return this.getEquipmentInteractionRegistry().getRunMovementMultiplierBonus(this.getInventory(), turnState); + return this.getEquipmentInteractionRegistry().getRunMovementMultiplierBonus( + this.getInventory(), + turnState, + this.getHandlerQueryContext(), + ); } getInventoryControlRules(): InventoryControlRules { const equipmentRules = this.injector.get(EquipmentInteractionRegistryService) .getRegistry() - .inventoryControlRules(this.getHandlerContext()); + .inventoryControlRules(this.getHandlerQueryContext()); return { ...equipmentRules, applyDisplayEffects: (entry, display, options) => { @@ -154,31 +183,50 @@ export class CBTForceUnit extends ForceUnit { }; } + /** Canonical status/profile-aware weapon types for domain rule evaluation. */ + getEffectiveWeaponTypes(entry: MountedWeapon): ReadonlySet { + const baseTypes = new Set(entry.getWeaponTypes(this.getInventoryControlSelectedAmmo(entry))); + return this.getEquipmentInteractionRegistry().applyWeaponTypes( + entry, + baseTypes, + this.getHandlerQueryContext(), + ); + } + + /** Canonical equipment-aware physical damage inputs for domain rule evaluation. */ + getEffectivePhysicalDamageEffect( + entry: MountedEquipment, + effect: InventoryControlPhysicalDamageEffect + ): InventoryControlPhysicalDamageEffect { + return this.getEquipmentInteractionRegistry().applyInventoryControlPhysicalDamageEffects( + entry, + effect, + this.getHandlerQueryContext(), + ); + } + getInventoryControlSelectedAmmo(entry: MountedEquipment, mode?: string | null): AmmoEquipmentType | null { - if (!(entry.equipment instanceof WeaponEquipment) || entry.equipment.ammoType === 'NA') return null; - const intrinsicAmmo = entry.linkedWith?.find(linked => linked instanceof MountedAmmo && linked.intrinsicOneShotAmmo); - if (intrinsicAmmo) { - // Rule evaluation needs only the selected profile. Going through the - // ammo summary also evaluates the source's availability, which checks - // this parent weapon's rule state and causes a reactive self-cycle. - const selectedAmmo = intrinsicAmmo.ammo - ? this.dataService.findEquipment(intrinsicAmmo.ammo) - : intrinsicAmmo.equipment; - return selectedAmmo instanceof AmmoEquipment ? selectedAmmo : null; - } - const summary = getInventoryControlModeAmmoSummary(entry, this.getEquipmentRegistry(), this.getInventoryControlRules(), mode); - return resolveInventoryControlSelectedAmmoOption( - summary.options, - this.getInventoryControlEntryAmmoOption(entry.id) - )?.ammo ?? null; + const selection = this.getInventoryControlEntryAmmoSelection(entry.id); + return resolveInventoryControlSelectedAmmoType( + entry, + this.getEquipmentRegistry(), + (weapon, ammo, selectedMode) => this.matchesInventoryControlAmmo(weapon, ammo, selectedMode), + selection, + mode, + ); } - private getHandlerContext() { - return { - toastService: this.injector.get(ToastService), - dialogsService: this.injector.get(DialogsService), - dataService: this.injector.get(DataService) - }; + matchesInventoryControlAmmo(entry: MountedEquipment, ammo: AmmoEquipmentType, mode: string | null): boolean | null { + return this.getEquipmentInteractionRegistry().matchesInventoryAmmo( + entry, + ammo, + mode, + this.getHandlerQueryContext(), + ); + } + + private getHandlerQueryContext() { + return createHandlerQueryContext(this.getEquipmentRegistry()); } applyInventoryControlDisplayEffects( @@ -212,7 +260,7 @@ export class CBTForceUnit extends ForceUnit { writeCrits(crits: CriticalSlot[]): void { this.state.crits.set(crits); this.turnState().reconcileHeatSources(); - this.inventoryControl.markInventoryViewChanged(); + this.inventoryControl.markAmmoSourcesChanged(); } override destroy() { @@ -369,7 +417,7 @@ export class CBTForceUnit extends ForceUnit { if (initialization) { this.turnState().capturePassiveHeatSourceBaseline(); } - this.inventoryControl.markInventoryViewChanged(); + this.inventoryControl.markAmmoSourcesChanged(); if (!initialization) { this.evaluateDestroyed(); this.setModified(); @@ -410,11 +458,9 @@ export class CBTForceUnit extends ForceUnit { if (slot.destroyed && !destroying) { slot.destroyed = undefined; // Reset destroyed immediately } - if (consolidateImmediately) { - slot.destroyed = slot.destroying; - } this.setCritSlot(slot); if (consolidateImmediately) { + this.dispatchBeforeEquipmentStateCommit(); this.state.consolidateCrits(); // Consolidate immediately in case we have pending hits to apply } this._rules.evaluateCritSlotHit(slot); @@ -445,7 +491,7 @@ export class CBTForceUnit extends ForceUnit { getOperationalMountedEquipmentByFlag(flag: EquipmentFlag): MountedEquipment[] { return this.getMountedEquipmentByFlag(flag) - .filter(entry => !this.isEquipmentUnavailable(entry)); + .filter(entry => this.isEquipmentOperational(entry)); } setInventory(inventory: MountedEquipment[], initialization: boolean = false) { @@ -454,7 +500,7 @@ export class CBTForceUnit extends ForceUnit { if (!initialization) { this.turnState().clampMoveDistanceToCurrentModeRange(); } - this.inventoryControl.markInventoryViewChanged(); + this.inventoryControl.markAmmoSourcesChanged(); if (!initialization) { this.setModified(); } @@ -507,8 +553,8 @@ export class CBTForceUnit extends ForceUnit { return this.inventoryControlRuntime.getEntryRange(entryId); } - getInventoryControlEntryAmmoOption(entryId: string): string | undefined { - return this.inventoryControlRuntime.getEntryAmmoOption(entryId); + getInventoryControlEntryAmmoSelection(entryId: string): InventoryControlRuntimeAmmoSelection | undefined { + return this.inventoryControlRuntime.getEntryAmmoSelection(entryId); } setInventoryControlEntrySelected(entry: MountedEquipment, selected: boolean): void { @@ -523,8 +569,8 @@ export class CBTForceUnit extends ForceUnit { this.inventoryControlRuntime.toggleEntryRange(entry, range, forceSelected); } - setInventoryControlEntryAmmoOption(entryId: string, optionId: string): void { - this.inventoryControlRuntime.setEntryAmmoOption(entryId, optionId); + setInventoryControlEntryAmmoSelection(entryId: string, selection: InventoryControlRuntimeAmmoSelection): void { + this.inventoryControlRuntime.setEntryAmmoSelection(entryId, selection); } setInventoryControlEntryTarget(entry: MountedEquipment, targetId: InventoryControlRuntimeTargetId | null): void { @@ -560,11 +606,11 @@ export class CBTForceUnit extends ForceUnit { isC3ComponentOperational(componentIndex: number, component?: C3Component): boolean { if (this.destroyed || this.getCondition('shutdown') || this.hasActiveC3DisruptingStealth()) return false; const mount = component?.mount ?? new C3Capabilities(this).component(componentIndex)?.mount; - return !!mount && !this.isEquipmentUnavailable(mount); + return !!mount && this.isEquipmentOperational(mount); } private hasActiveC3DisruptingStealth(): boolean { - return this.getInventory().some(equipment => !this.isEquipmentUnavailable(equipment) + return this.getInventory().some(equipment => this.isEquipmentOperational(equipment) && isC3DisruptingStealthActive(equipment)); } @@ -852,20 +898,254 @@ export class CBTForceUnit extends ForceUnit { return hits >= this.getInternalPoints(loc); } - isEquipmentUnavailable(source: MountedEquipment | CriticalSlot, loc?: string): boolean { - if (source instanceof MountedEquipment) { - if (source.isUnavailable()) return true; - return loc ? this.isEquipmentLocationUnavailable(loc) : Array.from(source.locations ?? []).some(loc => this.isEquipmentLocationUnavailable(loc)); + getEquipmentStatus(source: EquipmentStatusSource): EquipmentStatus { + if (!(source instanceof MountedEquipment)) return this.getCriticalSlotStatus(source); + + const facts = this.buildCurrentEquipmentStatusFacts(source); + return combineEquipmentStatuses([ + facts.mountState, + ...facts.locationStates.values(), + this.rules.getMountedCriticalStatusContribution(facts), + this.rules.getEquipmentStatusContribution(facts), + ]); + } + + getEquipmentInstallationLocationStatus(entry: MountedEquipment): EquipmentStatus { + const criticalSlots = this.getCurrentCriticalSlots(entry); + const locations = this.getEquipmentInstallationLocations(entry, criticalSlots); + return combineEquipmentStatuses(locations.map(location => this.getEquipmentLocationStatus(location))); + } + + getEquipmentStatusAtLocation(entry: MountedEquipment, location: string): EquipmentStatus { + const criticalSlots = this.getCurrentCriticalSlots(entry) + .filter(slot => slot.loc === location); + const facts = this.buildEquipmentStatusFacts(entry, criticalSlots, [location]); + return combineEquipmentStatuses([ + facts.mountState, + ...facts.locationStates.values(), + this.rules.getMountedCriticalStatusContribution(facts), + this.rules.getEquipmentStatusContributionAtLocation(facts, location), + ]); + } + + getEquipmentLocationStatus(location: string): EquipmentStatus { + if (!location) return 'available'; + const battleArmorLoc = this.battleArmorTrooperLocation(location); + if (battleArmorLoc) { + return this.isArmorLocCommittedDestroyed(battleArmorLoc, false) ? 'destroyed' : 'available'; + } + if (this.isInternalLocCommittedPhysicallyDestroyed(location)) return 'destroyed'; + if (this.isInternalLocCommittedDestroyed(location)) return 'disabled'; + return 'available'; + } + + isEquipmentOperational(source: EquipmentStatusSource): boolean { + return this.getEquipmentStatus(source) === 'available'; + } + + isEquipmentOperationalAtLocation(entry: MountedEquipment, location: string): boolean { + return this.getEquipmentStatusAtLocation(entry, location) === 'available'; + } + + isEquipmentResolvedDestroyed(entry: MountedEquipment): boolean { + if (this.getEquipmentInstallationLocationStatus(entry) === 'destroyed') return true; + if (entry.isRepairing()) return false; + return entry.isDestroying() || this.getEquipmentStatus(entry) === 'destroyed'; + } + + isEquipmentResolvedCommittedDestroyed(entry: MountedEquipment): boolean { + return this.getEquipmentInstallationLocationStatus(entry) === 'destroyed' + || (!entry.isRepairing() && this.getEquipmentStatus(entry) === 'destroyed'); + } + + canPerformEquipmentAction(entry: MountedEquipment, action: EquipmentAction): boolean { + if (action === 'configure-network') { + const component = new C3Capabilities(this).components.find(candidate => candidate.mount === entry); + if (!component || !this.isC3ComponentOperational(component.index, component)) return false; + } else if (!this.isEquipmentOperational(entry) || this.destroyed || this.getCondition('shutdown')) { + return false; + } + if (action === 'physical-attack' && this.isPhysicalActionUnavailable(entry)) return false; + return this.rules.canPerformEquipmentAction(entry, action); + } + + canEditEquipmentState(entry: MountedEquipment, edit: EquipmentStateEdit): boolean { + if (this.readOnly()) return false; + const status = this.getEquipmentStatus(entry); + switch (edit) { + case 'enable': + return status === 'disabled'; + case 'disable': + return status === 'available'; + case 'repair': + return this.getEquipmentInstallationLocationStatus(entry) !== 'destroyed' + && (entry.isDestroying() || (entry.committedDestroyed() && !entry.isRepairing())); + case 'apply-damage': + return !this.isEquipmentResolvedDestroyed(entry); + } + } + + applyEquipmentDamage(entry: MountedEquipment): boolean { + if (!this.canEditEquipmentState(entry, 'apply-damage')) return false; + if (!entry.setPendingDestroyed(true)) return false; + this.setInventoryEntry(entry); + return true; + } + + repairEquipment(entry: MountedEquipment): boolean { + if (!this.canEditEquipmentState(entry, 'repair')) return false; + if (!entry.setPendingDestroyed(false)) return false; + this.setInventoryEntry(entry); + return true; + } + + findCurrentCriticalSlot(slot: CriticalSlot): CriticalSlot | null { + const matches = this.getCritSlots().filter(candidate => { + if (slot.loc && slot.slot !== undefined) return candidate.loc === slot.loc && candidate.slot === slot.slot; + return !!slot.id && candidate.id === slot.id; + }); + if (matches.length > 1) { + throw new Error(`Duplicate critical-slot identity: ${slot.loc ?? slot.id}:${slot.slot ?? ''}`); } - return !!source.destroyed || this.isEquipmentLocationUnavailable(source.loc); + return matches[0] ?? null; + } + + private getCriticalSlotStatus(snapshot: CriticalSlot): EquipmentStatus { + const slot = this.findCurrentCriticalSlot(snapshot); + if (!slot) return 'available'; + const locationState = this.getEquipmentLocationStatus(slot.loc ?? ''); + const slotState: EquipmentStatus = slot.destroyed ? 'destroyed' : 'available'; + const facts: CriticalSlotStatusFacts = { + equipment: slot.eq ?? null, + equipmentId: slot.id ?? slot.name ?? '', + slotState, + locationState, + unitSystemFacts: this.rules.getUnitSystemStatusFacts(), + }; + return combineEquipmentStatuses([ + slotState, + locationState, + this.rules.getCriticalSlotStatusContribution(facts), + ]); + } + + private getCurrentCriticalSlots(entry: MountedEquipment): CriticalSlot[] { + return entry.critSlots?.flatMap(slot => this.findCurrentCriticalSlot(slot) ?? []) ?? []; + } + + private getEquipmentInstallationLocations(entry: MountedEquipment, criticalSlots: readonly CriticalSlot[]): string[] { + const componentRef = parseInventoryComponentReference(entry.id); + const referenceLocation = componentRef && this.isKnownEquipmentInstallationLocation(componentRef.location) + ? componentRef.location + : undefined; + const indexedComponent = componentRef === null ? undefined : this.getUnit().comp[componentRef.componentIndex]; + const componentLocation = referenceLocation === undefined + && indexedComponent + && this.isInventoryComponentForEntry(indexedComponent, entry) + ? indexedComponent.l + : undefined; + const rawLocations = [ + ...criticalSlots.flatMap(slot => slot.loc ? [slot.loc] : []), + ...(entry.locations ?? []), + ...(referenceLocation ? [referenceLocation] : []), + ...(componentLocation ? [componentLocation] : []), + ]; + const locations = [...new Set(rawLocations + .flatMap(location => location.split('/')) + .map(location => this.normalizeEquipmentInstallationLocation(location)) + .filter((location): location is string => location !== null))]; + if (locations.length > 0) return locations; + + if (entry.parent) { + const parentLocations = this.getEquipmentInstallationLocations( + entry.parent, + this.getCurrentCriticalSlots(entry.parent), + ); + if (parentLocations.length > 0) return parentLocations; + } + + this.reportUnknownDirectInventoryInstallationLocation(entry, componentRef); + return locations; + } + + private isInventoryComponentForEntry(component: Unit['comp'][number], entry: MountedEquipment): boolean { + return component.eq === entry.equipment + || component.id === entry.equipment?.internalName + || component.id === entry.name + || component.n === entry.name; + } + + private isKnownEquipmentInstallationLocation(location: string): boolean { + const normalizedLocations = location.split('/') + .map(candidate => this.normalizeEquipmentInstallationLocation(candidate)) + .filter((candidate): candidate is string => candidate !== null); + if (normalizedLocations.length === 0) return false; + + const metadataLocations = new Set(this.getUnit().comp + .flatMap(component => component.l?.split('/') ?? []) + .map(candidate => this.normalizeEquipmentInstallationLocation(candidate)) + .filter((candidate): candidate is string => candidate !== null)); + const structuralLocations = new Set([ + ...(this.locations?.internal.keys() ?? []), + ...Array.from(this.locations?.armor.values() ?? []).map(candidate => candidate.loc), + ]); + return normalizedLocations.every(candidate => + metadataLocations.has(candidate) || structuralLocations.has(candidate)); + } + + private normalizeEquipmentInstallationLocation(location: string): string | null { + const normalized = location.trim(); + if (!normalized || normalized === '—') return null; + return this.battleArmorTrooperLocation(normalized) ?? normalized; } - /** Whether equipment is temporarily unusable for an action, without implying damage. */ - isEquipmentActionUnavailable(source: MountedEquipment | CriticalSlot, loc?: string): boolean { - return this.destroyed - || this.getCondition('shutdown') - || (source instanceof MountedEquipment && this.isPhysicalActionUnavailable(source)) - || this.isEquipmentUnavailable(source, loc); + private reportUnknownDirectInventoryInstallationLocation( + entry: MountedEquipment, + componentRef: ReturnType, + ): void { + if (!this.isLoaded() || !this.hasDirectInventory() + || entry.isIntrinsicPhysicalAttack() || !entry.equipment + || this.unknownEquipmentInstallationLocationIds.has(entry.id)) return; + this.unknownEquipmentInstallationLocationIds.add(entry.id); + const componentLabel = componentRef === null ? '' : ` (component ${componentRef.componentIndex})`; + console.warn( + `Unable to resolve installation location for direct inventory equipment "${entry.id}"` + + `${componentLabel} on ${this.getUnit().name}.` + ); + } + + private buildEquipmentStatusFacts( + entry: MountedEquipment, + criticalSlots: readonly CriticalSlot[], + locations: readonly string[], + ): EquipmentStatusFacts { + const mountState: EquipmentStatus = entry.committedDestroyed() + ? 'destroyed' + : entry.states.get(ENTRY_DISABLED_STATE_KEY) === ENTRY_DISABLED_STATE_VALUE + ? 'disabled' + : 'available'; + return { + equipment: entry.equipment ?? null, + equipmentId: entry.equipment?.id ?? entry.id, + equipmentFlags: entry.equipment?.flags ?? new Set(), + mountState, + criticals: criticalSlots.map(slot => ({ + id: slot.id ?? `${slot.loc ?? ''}:${slot.slot ?? ''}`, + location: slot.loc ?? null, + slot: slot.slot ?? null, + status: slot.destroyed ? 'destroyed' : 'available', + committedHits: slot.hits ?? (slot.destroyed ? 1 : 0), + armored: slot.armored === true, + })), + locationStates: new Map(locations.map(location => [location, this.getEquipmentLocationStatus(location)])), + unitSystemFacts: this.rules.getUnitSystemStatusFacts(), + }; + } + + private buildCurrentEquipmentStatusFacts(entry: MountedEquipment): EquipmentStatusFacts { + const criticalSlots = this.getCurrentCriticalSlots(entry); + const locations = this.getEquipmentInstallationLocations(entry, criticalSlots); + return this.buildEquipmentStatusFacts(entry, criticalSlots, locations); } private isPhysicalActionUnavailable(entry: MountedEquipment): boolean { @@ -885,15 +1165,9 @@ export class CBTForceUnit extends ForceUnit { && (attack === 'charge' || attack === 'airmek ram' || attack === 'airmech ram'); } - private isEquipmentLocationUnavailable(loc: string | undefined): boolean { - if (!loc) return false; - const battleArmorLoc = this.battleArmorTrooperLocation(loc); - if (battleArmorLoc) return this.isArmorLocCommittedDestroyed(battleArmorLoc, false); - return this.isInternalLocCommittedDestroyed(loc); - } - private battleArmorTrooperLocation(loc: string): string | null { if (this.getUnit().subtype !== 'Battle Armor') return null; + if (loc.trim().toUpperCase() === 'SSW') return 'T1'; return getBattleArmorTrooperNumber(loc) === null ? null : normalizeBattleArmorTrooperLocation(loc); @@ -1161,6 +1435,7 @@ export class CBTForceUnit extends ForceUnit { return item; }); this.state.inventory.set([...inventory]); + this.inventoryControl.markAmmoSourcesChanged(); this.state.resetTurnState(); this.evaluateDestroyed(); this.setModified(); @@ -1192,10 +1467,26 @@ export class CBTForceUnit extends ForceUnit { PSRTargetRoll = computed(() => this._rules.PSRTargetRoll()); endPhase() { + this.dispatchBeforeEquipmentStateCommit(); this.state.endPhase(); this.phaseTrigger.update(v => v + 1); // Trigger change detection } + private dispatchBeforeEquipmentStateCommit(): void { + const equipmentRegistry = this.injector.get(EquipmentInteractionRegistryService).getRegistry(); + this.forEachCurrentInventoryEntry(entry => + equipmentRegistry.beforeEquipmentStateCommit(entry)); + } + + private forEachCurrentInventoryEntry(callback: (entry: MountedEquipment) => void): void { + // A lifecycle hook may rebuild the inventory, so reacquire each mount by stable ID. + const inventoryIds = this.getInventory().map(entry => entry.id); + for (const id of inventoryIds) { + const entry = this.getInventory().find(candidate => candidate.id === id); + if (entry) callback(entry); + } + } + applyHeat() { const heat = this.getHeat(); const projection = this.turnState().heatProjection(); @@ -1224,9 +1515,10 @@ export class CBTForceUnit extends ForceUnit { optionEl.classList.remove('selected'); }); }); + this.dispatchBeforeEquipmentStateCommit(); const equipmentRegistry = this.injector.get(EquipmentInteractionRegistryService).getRegistry(); - const handlerContext = this.getHandlerContext(); - this.getInventory().forEach(entry => equipmentRegistry.onEndTurn(entry, handlerContext)); + const notifications = this.injector.get(ToastService); + this.forEachCurrentInventoryEntry(entry => equipmentRegistry.onEndTurn(entry, notifications)); this.state.endTurn(); this.phaseTrigger.update(v => v + 1); // Trigger change detection this.state.resetTurnState(); diff --git a/src/app/models/cbt-inventory-control-runtime.model.spec.ts b/src/app/models/cbt-inventory-control-runtime.model.spec.ts new file mode 100644 index 000000000..5bdd89237 --- /dev/null +++ b/src/app/models/cbt-inventory-control-runtime.model.spec.ts @@ -0,0 +1,270 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { AmmoEquipment, WeaponEquipment } from './equipment.model'; +import { MountedAmmo, MountedWeapon } from './mounted-equipment.model'; +import { createCBTForceUnitTestHarness, type CBTForceUnitTestHarness } from '../testing/unit-test-helpers'; +import { + getInventoryControlAmmoProfileId, + getInventoryControlAmmoSelectionOptions, + getInventoryControlModeAmmoSummary, + resolveInventoryControlSelectedAmmoOption, +} from '../utils/inventory-control.util'; +import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from './rules/unit-type-rules'; + +describe('CBTInventoryControlRuntime ammo selection reconciliation', () => { + it('preserves a valid profile and source and can recover the profile from the actual source ID', () => { + const fixture = createAmmoFixture(); + const rightArmSourceId = `${fixture.standard.internalName}:RA`; + + fixture.harness.unit.setInventoryControlEntryAmmoSelection(fixture.weapon.id, { + selectedProfileId: null, + preferredSourceOptionId: rightArmSourceId, + }); + fixture.harness.runtime.markAmmoSourcesChanged(); + + expect(fixture.harness.unit.getInventoryControlEntryAmmoSelection(fixture.weapon.id)).toEqual({ + selectedProfileId: getInventoryControlAmmoProfileId(fixture.standard), + preferredSourceOptionId: rightArmSourceId, + }); + }); + + it('uses current location-qualified group IDs and keeps equivalent sources as one profile', () => { + const fixture = createAmmoFixture(); + + const options = getInventoryControlAmmoSelectionOptions( + fixture.weapon, + fixture.harness.equipmentRegistry, + ); + + expect(options.map(option => option.id)).toEqual([ + `${fixture.standard.internalName}:RA`, + `${fixture.standard.internalName}:LT`, + `${fixture.precision.internalName}:RT`, + ]); + expect(options.slice(0, 2).map(option => option.profileId)).toEqual([ + getInventoryControlAmmoProfileId(fixture.standard), + getInventoryControlAmmoProfileId(fixture.standard), + ]); + }); + + it('preserves the profile and clears only the preferred source when that source moves', () => { + const fixture = createAmmoFixture(); + const standardProfileId = getInventoryControlAmmoProfileId(fixture.standard); + fixture.harness.unit.setInventoryControlEntryAmmoSelection(fixture.weapon.id, { + selectedProfileId: standardProfileId, + preferredSourceOptionId: `${fixture.standard.internalName}:RA`, + }); + + fixture.harness.unit.setInventoryEntry(fixture.standardRightArm.clone({ + locations: new Set(['LT']), + })); + + const selection = fixture.harness.unit.getInventoryControlEntryAmmoSelection(fixture.weapon.id); + expect(selection).toEqual({ + selectedProfileId: standardProfileId, + preferredSourceOptionId: null, + }); + const summary = getInventoryControlModeAmmoSummary( + fixture.weapon, + fixture.harness.equipmentRegistry, + {}, + null, + ); + expect(resolveInventoryControlSelectedAmmoOption( + summary.options, + selection?.selectedProfileId, + selection?.preferredSourceOptionId, + )?.id).toBe(`${fixture.standard.internalName}:LT`); + }); + + it('preserves a catalog-valid profile when its last mounted source is removed', () => { + const fixture = createAmmoFixture(); + const standardProfileId = getInventoryControlAmmoProfileId(fixture.standard); + fixture.harness.unit.setInventoryControlEntryAmmoSelection(fixture.weapon.id, { + selectedProfileId: standardProfileId, + preferredSourceOptionId: `${fixture.standard.internalName}:RA`, + }); + + fixture.harness.unit.setInventory(fixture.harness.components.filter(entry => + entry !== fixture.standardRightArm && entry !== fixture.standardLeftTorso)); + + const selection = fixture.harness.unit.getInventoryControlEntryAmmoSelection(fixture.weapon.id); + expect(selection).toEqual({ + selectedProfileId: standardProfileId, + preferredSourceOptionId: null, + }); + expect(fixture.harness.unit.getInventoryControlSelectedAmmo(fixture.weapon)).toBe(fixture.standard); + const summary = getInventoryControlModeAmmoSummary( + fixture.weapon, + fixture.harness.equipmentRegistry, + {}, + null, + ); + expect(resolveInventoryControlSelectedAmmoOption( + summary.options, + selection?.selectedProfileId, + selection?.preferredSourceOptionId, + )).toBeUndefined(); + }); + + it('falls back deterministically when the selected profile becomes incompatible', () => { + const fixture = createAmmoFixture(); + fixture.harness.unit.setInventoryControlEntryAmmoSelection(fixture.weapon.id, { + selectedProfileId: getInventoryControlAmmoProfileId(fixture.standard), + preferredSourceOptionId: `${fixture.standard.internalName}:RA`, + }); + + fixture.harness.setInventoryControlRules({ + matchesAmmo: (_entry, ammo) => ammo !== fixture.standard, + }); + fixture.harness.runtime.markAmmoSourcesChanged(); + + expect(fixture.harness.unit.getInventoryControlEntryAmmoSelection(fixture.weapon.id)).toEqual({ + selectedProfileId: getInventoryControlAmmoProfileId(fixture.precision), + preferredSourceOptionId: null, + }); + expect(fixture.harness.unit.getInventoryControlSelectedAmmo(fixture.weapon)).toBe(fixture.precision); + }); + + it('resolves a selected profile without querying source or parent operational status', () => { + const fixture = createAmmoFixture(); + const operationalStatus = spyOn(fixture.harness.unit, 'isEquipmentOperational') + .and.throwError('Pure ammo-profile resolution queried equipment status'); + + expect(fixture.harness.unit.getInventoryControlSelectedAmmo(fixture.weapon)).toBe(fixture.standard); + expect(operationalStatus).not.toHaveBeenCalled(); + }); + + for (const scenario of getPreferredSourceUsabilityScenarios()) { + it(`clears an ${scenario.name} preferred source without losing or later restoring its selection`, () => { + const fixture = createAmmoFixture(); + const profileId = getInventoryControlAmmoProfileId(fixture.standard); + const sourceId = `${fixture.standard.internalName}:RA`; + + scenario.makeUnusable(fixture.standardRightArm); + fixture.harness.unit.setInventoryEntry(fixture.standardRightArm); + fixture.harness.unit.setInventoryControlEntryAmmoSelection(fixture.weapon.id, { + selectedProfileId: null, + preferredSourceOptionId: sourceId, + }); + fixture.harness.runtime.markAmmoSourcesChanged(); + + expectSelectionWithoutPreferredSource(fixture, profileId); + expect(getSourceOption(fixture, sourceId)?.usable).toBeFalse(); + + scenario.restore(fixture.standardRightArm); + fixture.harness.unit.setInventoryEntry(fixture.standardRightArm); + + expectSelectionWithoutPreferredSource(fixture, profileId); + expect(getSourceOption(fixture, sourceId)?.usable).toBeTrue(); + }); + } +}); + +interface AmmoFixture { + harness: CBTForceUnitTestHarness; + weapon: MountedWeapon; + standard: AmmoEquipment; + precision: AmmoEquipment; + standardRightArm: MountedAmmo; + standardLeftTorso: MountedAmmo; +} + +function getPreferredSourceUsabilityScenarios(): readonly { + name: string; + makeUnusable: (source: MountedAmmo) => void; + restore: (source: MountedAmmo) => void; +}[] { + return [ + { + name: 'empty', + makeUnusable: source => source.setAmmoState({ consumed: source.totalAmmo }), + restore: source => source.setAmmoState({ consumed: 0 }), + }, + { + name: 'destroyed', + makeUnusable: source => { source.setCommittedDestroyed(true); }, + restore: source => { source.setCommittedDestroyed(false); }, + }, + { + name: 'disabled', + makeUnusable: source => { source.setState(ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE); }, + restore: source => { source.deleteState(ENTRY_DISABLED_STATE_KEY); }, + }, + ]; +} + +function createAmmoFixture(): AmmoFixture { + const ac5 = new WeaponEquipment({ + id: 'Runtime AC5', + name: 'Runtime AC/5', + type: 'weapon', + weapon: { ammoType: 'AC', rackSize: 5, damage: 5 }, + }); + const standard = new AmmoEquipment({ + id: 'Runtime AC5 Standard Ammo', + name: 'Runtime AC/5 Standard Ammo', + type: 'ammo', + ammo: { type: 'AC', rackSize: 5, shots: 20, munitionType: ['M_STANDARD'] }, + }); + const precision = new AmmoEquipment({ + id: 'Runtime AC5 Precision Ammo', + name: 'Runtime AC/5 Precision Ammo', + type: 'ammo', + ammo: { type: 'AC', rackSize: 5, shots: 10, munitionType: ['M_PRECISION'] }, + }); + const harness = createCBTForceUnitTestHarness({ tracksHeat: false }); + const weapon = harness.addComponent({ + id: 'runtime-ac5', + name: ac5.name, + equipment: ac5, + locations: new Set(['RT']), + }) as MountedWeapon; + const standardRightArm = harness.addComponent({ + id: 'runtime-standard-ra', + name: standard.name, + equipment: standard, + locations: new Set(['RA']), + totalAmmo: 20, + }) as MountedAmmo; + const standardLeftTorso = harness.addComponent({ + id: 'runtime-standard-lt', + name: standard.name, + equipment: standard, + locations: new Set(['LT']), + totalAmmo: 20, + }) as MountedAmmo; + harness.addComponent({ + id: 'runtime-precision-rt', + name: precision.name, + equipment: precision, + locations: new Set(['RT']), + totalAmmo: 10, + }); + + return { + harness, + weapon, + standard, + precision, + standardRightArm, + standardLeftTorso, + }; +} + +function expectSelectionWithoutPreferredSource(fixture: AmmoFixture, profileId: string): void { + expect(fixture.harness.unit.getInventoryControlEntryAmmoSelection(fixture.weapon.id)).toEqual({ + selectedProfileId: profileId, + preferredSourceOptionId: null, + }); + expect(fixture.harness.unit.getInventoryControlSelectedAmmo(fixture.weapon)).toBe(fixture.standard); +} + +function getSourceOption(fixture: AmmoFixture, sourceId: string) { + return getInventoryControlAmmoSelectionOptions( + fixture.weapon, + fixture.harness.equipmentRegistry, + ).find(option => option.id === sourceId); +} diff --git a/src/app/models/cbt-inventory-control-runtime.model.ts b/src/app/models/cbt-inventory-control-runtime.model.ts index 987834cbb..38b54d453 100644 --- a/src/app/models/cbt-inventory-control-runtime.model.ts +++ b/src/app/models/cbt-inventory-control-runtime.model.ts @@ -8,13 +8,16 @@ import type { CBTForceUnit } from './cbt-force-unit.model'; import { InventoryControlRuntimeState, mergeInventoryControlCalculatorState, + reconcileInventoryControlRuntimeAmmoSelection, splitInventoryControlCalculatorState, type InventoryControlRuntimeRangeKey, + type InventoryControlRuntimeAmmoSelection, type InventoryControlRuntimeTarget, type InventoryControlRuntimeTargetId, type InventoryControlUnitTargetState } from './inventory-control-runtime-state.model'; import { calculateTargetTnModifier } from './target-number-calculator.model'; +import { getInventoryControlAmmoSelectionCandidates } from '../utils/inventory-control.util'; export class CBTInventoryControlRuntime extends InventoryControlRuntimeState { private readonly unitTargetStates = signal>(new Map()); @@ -22,7 +25,21 @@ export class CBTInventoryControlRuntime extends InventoryControlRuntimeState { constructor(private readonly unit: CBTForceUnit) { super( () => unit.getInventory(), - targetId => unit.getInventoryControlTargetsMap().has(targetId) + targetId => unit.getInventoryControlTargetsMap().has(targetId), + (entry, selection) => { + const candidates = getInventoryControlAmmoSelectionCandidates( + entry, + unit.getEquipmentRegistry(), + (weapon, ammo, mode) => unit.matchesInventoryControlAmmo(weapon, ammo, mode), + undefined, + true, + ); + return reconcileInventoryControlRuntimeAmmoSelection( + selection, + candidates.sourceOptions, + candidates.profileOptions, + ); + }, ); } @@ -128,8 +145,9 @@ export class CBTInventoryControlRuntime extends InventoryControlRuntimeState { this.markInventoryViewChanged(); } - override setEntryAmmoOption(entryId: string, optionId: string): void { - super.setEntryAmmoOption(entryId, optionId); + override setEntryAmmoSelection(entryId: string, selection: InventoryControlRuntimeAmmoSelection): void { + super.setEntryAmmoSelection(entryId, selection); + this.reconcileAmmoSelections(); this.markInventoryViewChanged(); } @@ -143,4 +161,9 @@ export class CBTInventoryControlRuntime extends InventoryControlRuntimeState { this.markInventoryViewChanged(); } + markAmmoSourcesChanged(): void { + this.reconcileAmmoSelections(); + super.markInventoryViewChanged(); + } + } diff --git a/src/app/models/equipment-status.model.spec.ts b/src/app/models/equipment-status.model.spec.ts new file mode 100644 index 000000000..25bbef134 --- /dev/null +++ b/src/app/models/equipment-status.model.spec.ts @@ -0,0 +1,20 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import { combineEquipmentStatuses } from './equipment-status.model'; + +describe('equipment status', () => { + it('returns available for no restrictions', () => { + expect(combineEquipmentStatuses([])).toBe('available'); + expect(combineEquipmentStatuses(['available'])).toBe('available'); + }); + + it('gives disabled precedence over available', () => { + expect(combineEquipmentStatuses(['available', 'disabled'])).toBe('disabled'); + }); + + it('gives destroyed precedence over every other status', () => { + expect(combineEquipmentStatuses(['disabled', 'destroyed', 'available'])).toBe('destroyed'); + }); +}); \ No newline at end of file diff --git a/src/app/models/equipment-status.model.ts b/src/app/models/equipment-status.model.ts new file mode 100644 index 000000000..599b45f89 --- /dev/null +++ b/src/app/models/equipment-status.model.ts @@ -0,0 +1,45 @@ +// Copyright (C) 2026 The MegaMek Team +// SPDX-License-Identifier: GPL-3.0-or-later +// Author: Drake + +import type { Equipment } from './equipment.model'; +import type { EquipmentFlag } from './equipment-flags.type'; + +export type EquipmentStatus = 'available' | 'disabled' | 'destroyed'; + +export interface UnitSystemStatusFacts { + readonly engineHit: boolean; +} + +export interface EquipmentStatusFacts { + readonly equipment: Equipment | null; + readonly equipmentId: string; + readonly equipmentFlags: ReadonlySet; + readonly mountState: EquipmentStatus; + readonly criticals: readonly MountedCriticalFact[]; + readonly locationStates: ReadonlyMap; + readonly unitSystemFacts: UnitSystemStatusFacts; +} + +export interface MountedCriticalFact { + readonly id: string; + readonly location: string | null; + readonly slot: number | null; + readonly status: EquipmentStatus; + readonly committedHits: number; + readonly armored: boolean; +} + +export interface CriticalSlotStatusFacts { + readonly equipment: Equipment | null; + readonly equipmentId: string; + readonly slotState: EquipmentStatus; + readonly locationState: EquipmentStatus; + readonly unitSystemFacts: UnitSystemStatusFacts; +} + +export function combineEquipmentStatuses(statuses: readonly EquipmentStatus[]): EquipmentStatus { + if (statuses.includes('destroyed')) return 'destroyed'; + if (statuses.includes('disabled')) return 'disabled'; + return 'available'; +} diff --git a/src/app/models/equipment.model.spec.ts b/src/app/models/equipment.model.spec.ts index 1d720f612..f4dcf49ab 100644 --- a/src/app/models/equipment.model.spec.ts +++ b/src/app/models/equipment.model.spec.ts @@ -585,7 +585,10 @@ describe('equipment damage types', () => { ammo: { type: 'SNIPER_CANNON', munitionType: ['M_FLAK'] } }); const owner = { - getInventoryControlEntryAmmoOption: () => `${flak.internalName}:Front`, + getInventoryControlEntryAmmoSelection: () => ({ + selectedProfileId: `${flak.internalName}||M_FLAK`, + preferredSourceOptionId: `${flak.internalName}:Front`, + }), getEquipmentRegistry: () => new EquipmentRegistry({ [flak.internalName]: flak }) } as unknown as CBTForceUnit; const mounted = new MountedWeapon({ owner, id: weapon.id, name: weapon.name, equipment: weapon }); diff --git a/src/app/models/inventory-component-reference.model.spec.ts b/src/app/models/inventory-component-reference.model.spec.ts index 48253c572..283b4fa18 100644 --- a/src/app/models/inventory-component-reference.model.spec.ts +++ b/src/app/models/inventory-component-reference.model.spec.ts @@ -6,14 +6,18 @@ import { parseInventoryComponentReference } from './inventory-component-referenc describe('parseInventoryComponentReference', () => { it('parses component and optional bin indexes', () => { - expect(parseInventoryComponentReference('Ammo@RT#3')).toEqual({ componentIndex: 3, binIndex: null }); - expect(parseInventoryComponentReference('Ammo@RT#3.2')).toEqual({ componentIndex: 3, binIndex: 2 }); + expect(parseInventoryComponentReference('Ammo@RT#3')).toEqual({ location: 'RT', componentIndex: 3, binIndex: null }); + expect(parseInventoryComponentReference('Ammo@RT#3.2')).toEqual({ location: 'RT', componentIndex: 3, binIndex: 2 }); + expect(parseInventoryComponentReference('Equipment@C/R/LT#12')).toEqual({ + location: 'C/R/LT', componentIndex: 12, binIndex: null, + }); }); it('rejects malformed and negative component references', () => { expect(parseInventoryComponentReference('Ammo@RT')).toBeNull(); + expect(parseInventoryComponentReference('Ammo@#3')).toBeNull(); expect(parseInventoryComponentReference('Ammo@RT#-1.0')).toBeNull(); expect(parseInventoryComponentReference('Ammo@RT#3.-1')).toBeNull(); expect(parseInventoryComponentReference('Ammo@RT#three.0')).toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/src/app/models/inventory-component-reference.model.ts b/src/app/models/inventory-component-reference.model.ts index 62a826d9d..4ae809bc0 100644 --- a/src/app/models/inventory-component-reference.model.ts +++ b/src/app/models/inventory-component-reference.model.ts @@ -3,19 +3,20 @@ // Author: Drake export interface InventoryComponentReference { + location: string; componentIndex: number; binIndex: number | null; } /** Parses the stable `equipment@location#component[.bin]` inventory ID suffix. */ export function parseInventoryComponentReference(id: string): InventoryComponentReference | null { - const suffix = id.split('#').pop(); - if (!suffix) return null; + const match = id.match(/@([^#]+)#(\d+)(?:\.(\d+))?$/); + if (!match) return null; - const [componentIndexText, binIndexText] = suffix.split('.'); - const componentIndex = Number(componentIndexText); - const binIndex = binIndexText === undefined ? null : Number(binIndexText); - if (!Number.isInteger(componentIndex) || componentIndex < 0) return null; - if (binIndex !== null && (!Number.isInteger(binIndex) || binIndex < 0)) return null; - return { componentIndex, binIndex }; -} \ No newline at end of file + const location = match[1].trim(); + const componentIndex = Number(match[2]); + const binIndex = match[3] === undefined ? null : Number(match[3]); + if (!location || !Number.isSafeInteger(componentIndex)) return null; + if (binIndex !== null && !Number.isSafeInteger(binIndex)) return null; + return { location, componentIndex, binIndex }; +} diff --git a/src/app/models/inventory-control-runtime-state.model.ts b/src/app/models/inventory-control-runtime-state.model.ts index 98d02f5aa..520e567e2 100644 --- a/src/app/models/inventory-control-runtime-state.model.ts +++ b/src/app/models/inventory-control-runtime-state.model.ts @@ -96,10 +96,53 @@ export interface InventoryControlRuntimeSnapshot { export interface InventoryControlRuntimeEntryState { selected: boolean; range?: InventoryControlRuntimeRangeKey; - ammoOption?: string; + ammoSelection?: InventoryControlRuntimeAmmoSelection; targetId?: InventoryControlRuntimeTargetId; } +export interface InventoryControlRuntimeAmmoSelection { + readonly selectedProfileId: string | null; + readonly preferredSourceOptionId: string | null; +} + +export interface InventoryControlRuntimeAmmoOptionIdentity { + readonly id: string; + readonly profileId: string; + readonly usable: boolean; +} + +export interface InventoryControlRuntimeAmmoProfileIdentity { + readonly profileId: string; +} + +export function reconcileInventoryControlRuntimeAmmoSelection( + selection: InventoryControlRuntimeAmmoSelection | undefined, + sourceOptions: readonly InventoryControlRuntimeAmmoOptionIdentity[], + profileOptions: readonly InventoryControlRuntimeAmmoProfileIdentity[], +): InventoryControlRuntimeAmmoSelection | undefined { + if (!selection || profileOptions.length === 0) return undefined; + + const preferredSource = selection.preferredSourceOptionId + ? sourceOptions.find(option => option.id === selection.preferredSourceOptionId) + : undefined; + const persistedProfileId = selection.selectedProfileId + && profileOptions.some(option => option.profileId === selection.selectedProfileId) + ? selection.selectedProfileId + : null; + const preferredProfileId = preferredSource + && profileOptions.some(option => option.profileId === preferredSource.profileId) + ? preferredSource.profileId + : null; + const selectedProfileId = persistedProfileId ?? preferredProfileId ?? profileOptions[0].profileId; + + return { + selectedProfileId, + preferredSourceOptionId: preferredSource?.profileId === selectedProfileId && preferredSource.usable + ? preferredSource.id + : null, + }; +} + export function getInventoryControlTargetLetter(index: number): string { let value = index + 1; let label = ''; @@ -127,7 +170,11 @@ export class InventoryControlRuntimeState { constructor( private readonly getInventory: () => MountedEquipment[], - private readonly isTargetValid: (targetId: InventoryControlRuntimeTargetId) => boolean = targetId => this.targetsMap().has(targetId) + private readonly isTargetValid: (targetId: InventoryControlRuntimeTargetId) => boolean = targetId => this.targetsMap().has(targetId), + private readonly reconcileAmmoSelection: ( + entry: MountedEquipment, + selection: InventoryControlRuntimeAmmoSelection, + ) => InventoryControlRuntimeAmmoSelection | undefined = (_entry, selection) => selection, ) {} getSnapshot(): InventoryControlRuntimeSnapshot { @@ -150,7 +197,7 @@ export class InventoryControlRuntimeState { getEntryState(entryId: string): InventoryControlRuntimeEntryState | undefined { const entryState = this.entryStatesState().get(entryId); - return entryState ? { ...entryState } : undefined; + return entryState ? this.cloneEntryState(entryState) : undefined; } getEntryTargetId(entryId: string): InventoryControlRuntimeTargetId | undefined { @@ -165,8 +212,9 @@ export class InventoryControlRuntimeState { return this.entryStatesState().get(entryId)?.range; } - getEntryAmmoOption(entryId: string): string | undefined { - return this.entryStatesState().get(entryId)?.ammoOption; + getEntryAmmoSelection(entryId: string): InventoryControlRuntimeAmmoSelection | undefined { + const selection = this.entryStatesState().get(entryId)?.ammoSelection; + return selection ? { ...selection } : undefined; } setEntrySelected(entry: MountedEquipment, selected: boolean): void { @@ -197,9 +245,9 @@ export class InventoryControlRuntimeState { this.setEntryRange(entry, !forceSelected && selected ? null : range); } - setEntryAmmoOption(entryId: string, optionId: string): void { + setEntryAmmoSelection(entryId: string, selection: InventoryControlRuntimeAmmoSelection): void { this.updateEntryState(entryId, entryState => { - entryState.ammoOption = optionId; + entryState.ammoSelection = { ...selection }; }); } @@ -327,6 +375,25 @@ export class InventoryControlRuntimeState { } } }); + this.reconcileAmmoSelections(); + } + + reconcileAmmoSelections(): void { + const entriesById = new Map(this.getInventory().map(entry => [entry.id, entry])); + this.updateEntryStates(entryStates => { + for (const [entryId, entryState] of entryStates) { + if (!entryState.ammoSelection) continue; + const entry = entriesById.get(entryId); + const selection = entry + ? this.reconcileAmmoSelection(entry, entryState.ammoSelection) + : undefined; + if (selection) { + entryState.ammoSelection = { ...selection }; + } else { + delete entryState.ammoSelection; + } + } + }); } markInventoryViewChanged(): void { @@ -378,12 +445,22 @@ export class InventoryControlRuntimeState { if (entryState.targetId) { delete entryState.range; } - if (!entryState.selected && entryState.ammoOption === undefined) return null; - return { ...entryState }; + if (!entryState.selected && entryState.ammoSelection === undefined) return null; + return this.cloneEntryState(entryState); } private cloneEntryStates(entryStates: Map): Map { - return new Map(Array.from(entryStates, ([entryId, entryState]) => [entryId, { ...entryState }])); + return new Map(Array.from(entryStates, ([entryId, entryState]) => [ + entryId, + this.cloneEntryState(entryState), + ])); + } + + private cloneEntryState(entryState: InventoryControlRuntimeEntryState): InventoryControlRuntimeEntryState { + return { + ...entryState, + ...(entryState.ammoSelection && { ammoSelection: { ...entryState.ammoSelection } }), + }; } private cloneTarget(target: InventoryControlRuntimeTarget): InventoryControlRuntimeTarget { @@ -397,4 +474,4 @@ export class InventoryControlRuntimeState { return next; }); } -} \ No newline at end of file +} diff --git a/src/app/models/mounted-equipment.model.spec.ts b/src/app/models/mounted-equipment.model.spec.ts index 5b0faa773..682623062 100644 --- a/src/app/models/mounted-equipment.model.spec.ts +++ b/src/app/models/mounted-equipment.model.spec.ts @@ -4,7 +4,6 @@ import { AmmoEquipment, MiscEquipment, WeaponEquipment } from './equipment.model'; import { getMountedOneShotConsumed, MountedAmmo, MountedEquipment, MountedWeapon } from './mounted-equipment.model'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; describe('MountedAmmo capacity baseline', () => { const ammoEquipment = new AmmoEquipment({ @@ -150,34 +149,6 @@ describe('MountedEquipment physical classification', () => { }); }); -describe('MountedEquipment action availability', () => { - it('delegates action availability to its owning unit', () => { - const owner = jasmine.createSpyObj('CBTForceUnit', ['isEquipmentActionUnavailable']); - owner.rules = createTestEquipmentRules(); - const entry = new MountedEquipment({ owner, id: 'laser', name: 'Laser' }); - owner.isEquipmentActionUnavailable.and.returnValue(false); - - expect(entry.isActionUnavailable()).toBeFalse(); - expect(owner.isEquipmentActionUnavailable).toHaveBeenCalledOnceWith(entry); - - owner.isEquipmentActionUnavailable.calls.reset(); - owner.isEquipmentActionUnavailable.and.returnValue(true); - - expect(entry.isActionUnavailable()).toBeTrue(); - expect(owner.isEquipmentActionUnavailable).toHaveBeenCalledOnceWith(entry); - }); - - it('is action-unavailable when structurally unavailable without consulting its owner', () => { - const owner = jasmine.createSpyObj('CBTForceUnit', ['isEquipmentActionUnavailable']); - owner.rules = createTestEquipmentRules({ getEquipmentStatus: () => 'destroyed' }); - const entry = new MountedEquipment({ owner, id: 'laser', name: 'Laser' }); - - expect(entry.isUnavailable()).toBeTrue(); - expect(entry.isActionUnavailable()).toBeTrue(); - expect(owner.isEquipmentActionUnavailable).not.toHaveBeenCalled(); - }); -}); - describe('MountedEquipment relationships', () => { const owner = {} as never; const entry = (id: string, entryOwner = owner) => new MountedEquipment({ diff --git a/src/app/models/mounted-equipment.model.ts b/src/app/models/mounted-equipment.model.ts index 769d55d04..3e177cb01 100644 --- a/src/app/models/mounted-equipment.model.ts +++ b/src/app/models/mounted-equipment.model.ts @@ -11,7 +11,7 @@ import type { CriticalSlot } from './force-serialization'; import { isPhysicalWeaponEquipment } from './entity/utils/physical-weapon'; export interface MountedEquipmentInit { - owner: CBTForceUnit; + readonly owner: CBTForceUnit; id: string; name: string; locations?: Set; @@ -252,32 +252,6 @@ export class MountedEquipment { }; } - isDestroyed(): boolean { - return this.owner.rules.getEquipmentStatus(this) === 'destroyed'; - } - - isDisabled(): boolean { - return this.owner.rules.getEquipmentStatus(this) === 'disabled'; - } - - isUnavailable(): boolean { - return this.owner.rules.getEquipmentStatus(this) !== 'available'; - } - - /** Whether this mount is structurally unavailable or temporarily unable to act. */ - isActionUnavailable(): boolean { - return this.isUnavailable() || this.owner.isEquipmentActionUnavailable(this); - } - - resolvedDestroyed(ruleDamaged: boolean = this.isDestroyed()): boolean { - if (this.isRepairing()) return false; - return this.isDestroying() || ruleDamaged; - } - - resolvedCommittedDestroyed(ruleDamaged: boolean = this.isDestroyed()): boolean { - return !this.isRepairing() && ruleDamaged; - } - committedDestroyedState(): boolean | undefined { return this.destroyedState(); } @@ -290,10 +264,6 @@ export class MountedEquipment { return !!this.committedDestroyedState(); } - effectiveDestroyed(): boolean { - return this.pendingDestroyed() ?? this.committedDestroyed(); - } - hasPendingDestroyedChange(): boolean { return this.pendingDestroyed() !== undefined; } diff --git a/src/app/models/rules/aero-rules.spec.ts b/src/app/models/rules/aero-rules.spec.ts index cb43c63cf..c4d8a579c 100644 --- a/src/app/models/rules/aero-rules.spec.ts +++ b/src/app/models/rules/aero-rules.spec.ts @@ -12,7 +12,7 @@ function createHarness(heat: number, physical = false): { rules: AeroRules; entr getHeat: () => ({ current: heat }), getInventory: () => [], getCritSlots: () => [], - isEquipmentUnavailable: () => false, + isEquipmentOperational: () => true, } as unknown as CBTForceUnit; const entry = { committedDestroyed: () => false, @@ -27,37 +27,33 @@ function createHarness(heat: number, physical = false): { rules: AeroRules; entr describe('AeroRules', () => { it('does not apply a fire modifier below the first heat threshold', () => { const { rules, entry } = createHarness(7); - const state = rules.getEquipmentToHit(entry); + const modifiers = rules.getEquipmentToHitModifiers(entry); - expect(state.modifier).toBe(0); - expect(state.modifiers).toEqual([]); + expect(modifiers).toEqual([]); }); it('includes heat as a named weakened entry-state modifier', () => { const { rules, entry } = createHarness(8); - const state = rules.getEquipmentToHit(entry); + const modifiers = rules.getEquipmentToHitModifiers(entry); - expect(state.modifier).toBe(1); - expect(state.modifiers).toEqual([ + expect(modifiers).toEqual([ { label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' } ]); }); it('uses the cumulative modifier at higher heat thresholds', () => { const { rules, entry } = createHarness(24); - const state = rules.getEquipmentToHit(entry); + const modifiers = rules.getEquipmentToHitModifiers(entry); - expect(state.modifier).toBe(4); - expect(state.modifiers).toEqual([ + expect(modifiers).toEqual([ { label: 'Heat - Fire Modifier', modifier: 4, weakened: true, kind: 'heat' } ]); }); it('does not apply heat fire modifiers to physical attacks', () => { const { rules, entry } = createHarness(24, true); - const state = rules.getEquipmentToHit(entry); + const modifiers = rules.getEquipmentToHitModifiers(entry); - expect(state.modifier).toBe(0); - expect(state.modifiers).toEqual([]); + expect(modifiers).toEqual([]); }); }); \ No newline at end of file diff --git a/src/app/models/rules/aimed-shot.util.ts b/src/app/models/rules/aimed-shot.util.ts index 0a3bf05c5..99eadaddc 100644 --- a/src/app/models/rules/aimed-shot.util.ts +++ b/src/app/models/rules/aimed-shot.util.ts @@ -124,4 +124,4 @@ function allowed(): AimedShotEligibility { function disallowed(reason: string): AimedShotEligibility { return { allowed: false, reason }; -} \ No newline at end of file +} diff --git a/src/app/models/rules/game-rules.spec.ts b/src/app/models/rules/game-rules.spec.ts index 639c28254..62544de0a 100644 --- a/src/app/models/rules/game-rules.spec.ts +++ b/src/app/models/rules/game-rules.spec.ts @@ -6,21 +6,14 @@ import { EquipmentFlag } from '../equipment-flags.type'; import { EquipmentRegistry } from '../equipment-lookup'; import { AmmoEquipment, MiscEquipment, WeaponEquipment, type Equipment } from '../equipment.model'; import { MountedEquipment } from '../mounted-equipment.model'; -import { createTestEquipmentRules } from '../../testing/unit-test-helpers'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, separateHeatFireModifier } from './game-rules'; let entryId = 0; function owner() { return { - rules: { - ...createTestEquipmentRules({ - getEquipmentStatus: (candidate: MountedEquipment) => ( - candidate.committedDestroyed() ? 'destroyed' : 'available' - ), - }), - heatDissipation: () => null - } + getEquipmentStatus: (candidate: MountedEquipment) => candidate.committedDestroyed() ? 'destroyed' : 'available', + isEquipmentOperational: (candidate: MountedEquipment) => !candidate.committedDestroyed(), } as never; } @@ -78,8 +71,19 @@ function tagBvContext(options: { })); for (const index of options.unavailableTagIndexes ?? []) unavailable.add(tagMounts[index]); - const launcher = new MountedEquipment({ - owner: null!, + let launcher!: MountedEquipment; + let ammo!: MountedEquipment; + const ammoUnit = { + isLoaded: () => options.loaded !== false, + getUnit: () => ({ type: options.unitType ?? 'Tank' }), + getInventory: () => options.unitType === 'Mek' ? [launcher] : [launcher, ammo], + getCritSlots: () => options.unitType === 'Mek' ? [{ + id: 'ammo-crit', eq: ammo.equipment, totalAmmo: ammo.totalAmmo, consumed: ammo.consumed, + }] : [], + isEquipmentOperational: (entry: MountedEquipment) => !unavailable.has(entry), + } as unknown as import('../cbt-force-unit.model').CBTForceUnit; + launcher = new MountedEquipment({ + owner: ammoUnit, id: 'launcher', name: 'LRM Launcher', equipment: new WeaponEquipment({ @@ -88,8 +92,8 @@ function tagBvContext(options: { }), states: new Map(), }); - const ammo = new MountedEquipment({ - owner: null!, + ammo = new MountedEquipment({ + owner: ammoUnit, id: 'ammo', name: 'Semi-Guided LRM 20 Ammo', equipment: new AmmoEquipment({ @@ -106,17 +110,6 @@ function tagBvContext(options: { consumed: options.ammoAvailable === false ? 6 : 0, states: new Map(), }); - const ammoUnit = { - isLoaded: () => options.loaded !== false, - getUnit: () => ({ type: options.unitType ?? 'Tank' }), - getInventory: () => options.unitType === 'Mek' ? [launcher] : [launcher, ammo], - getCritSlots: () => options.unitType === 'Mek' ? [{ - id: 'ammo-crit', eq: ammo.equipment, totalAmmo: ammo.totalAmmo, consumed: ammo.consumed, - }] : [], - isEquipmentUnavailable: (entry: MountedEquipment) => unavailable.has(entry), - } as unknown as import('../cbt-force-unit.model').CBTForceUnit; - launcher.owner = ammoUnit; - ammo.owner = ammoUnit; if (options.ammoAvailable === false) unavailable.add(ammo); if (options.launcherAvailable === false) unavailable.add(launcher); @@ -159,8 +152,18 @@ function vehicleTagBvContext(options: { [baseAmmo.id]: baseAmmo, [selectedAmmo.id]: selectedAmmo, }); - const launcher = new MountedEquipment({ - owner: null!, + let launcher!: MountedEquipment; + let ammo!: MountedEquipment; + const ammoUnit = { + isLoaded: () => true, + getUnit: () => ({ type: 'Tank' }), + getInventory: () => [launcher, ammo], + getCritSlots: () => [], + getEquipmentRegistry: () => registry, + isEquipmentOperational: (entry: MountedEquipment) => !unavailable.has(entry), + } as unknown as import('../cbt-force-unit.model').CBTForceUnit; + launcher = new MountedEquipment({ + owner: ammoUnit, id: 'launcher', name: 'LRM 20', equipment: new WeaponEquipment({ @@ -171,8 +174,8 @@ function vehicleTagBvContext(options: { }), states: new Map(), }); - const ammo = new MountedEquipment({ - owner: null!, + ammo = new MountedEquipment({ + owner: ammoUnit, id: 'ammo', name: baseAmmo.id, equipment: baseAmmo, @@ -180,16 +183,6 @@ function vehicleTagBvContext(options: { totalAmmo: 6, states: new Map(), }); - const ammoUnit = { - isLoaded: () => true, - getUnit: () => ({ type: 'Tank' }), - getInventory: () => [launcher, ammo], - getCritSlots: () => [], - getEquipmentRegistry: () => registry, - isEquipmentUnavailable: (entry: MountedEquipment) => unavailable.has(entry), - } as unknown as import('../cbt-force-unit.model').CBTForceUnit; - launcher.owner = ammoUnit; - ammo.owner = ammoUnit; const tagUnit = { getOperationalMountedEquipmentByFlag: () => tagMounts.filter(mount => !unavailable.has(mount)), @@ -393,7 +386,7 @@ describe('game rules', () => { const resolution = CORE_2026_GAME_RULES.resolveToHit({ subject: weapon, - adjustments: [{ kind: 'replace-base', value: 0 }] + adjustments: [{ kind: 'replace-base', value: 0, label: 'Explicit Zero Override' }] }); expect(resolution.value).toBe(0); @@ -405,9 +398,9 @@ describe('game rules', () => { const resolution = CORE_2026_GAME_RULES.resolveToHit({ subject: mountedWeapon(-2), adjustments: [ - { kind: 'replace-base', value: 0 }, - { kind: 'replace-base', value: 4 }, - { kind: 'add', modifier: 1 } + { kind: 'replace-base', value: 0, label: 'First Base Override' }, + { kind: 'replace-base', value: 4, label: 'Second Base Override' }, + { kind: 'add', modifier: 1, label: 'Positive Adjustment' } ] }); @@ -453,14 +446,14 @@ describe('game rules', () => { expect(CORE_2026_GAME_RULES.resolveToHit({ subject: launcher, - adjustments: [{ kind: 'add', modifier: 1 }] + adjustments: [{ kind: 'add', label: 'Linked equipment', modifier: 1 }] }).value).toBe(0); }); it('reports changed and weakened metadata without a second resolution', () => { const resolution = CORE_2026_GAME_RULES.resolveToHit({ subject: mountedWeapon(-2), - stateModifier: 1, + stateModifiers: [{ label: 'Hit Modifier', modifier: 1 }], adjustments: [{ kind: 'add', label: 'Lost bonus', @@ -482,8 +475,7 @@ describe('game rules', () => { it('marks a canceled adverse state modifier as weakened', () => { const resolution = CORE_2026_GAME_RULES.resolveToHit({ subject: mountedWeapon(0), - stateModifier: 0, - stateModifierBreakdown: [ + stateModifiers: [ { label: 'Targeting Computer', modifier: -1 }, { label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' } ] @@ -498,24 +490,24 @@ describe('game rules', () => { ]); }); - it('does not trust adverse provenance whose total differs from the state modifier', () => { + it('derives adverse state totals from their provenance', () => { const resolution = CORE_2026_GAME_RULES.resolveToHit({ subject: mountedWeapon(0), - stateModifier: 0, - stateModifierBreakdown: [{ label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' }] + stateModifiers: [{ label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' }] }); - expect(resolution.value).toBe(0); - expect(resolution.weakened).toBeFalse(); - expect(resolution.modifierBreakdown).toEqual([]); + expect(resolution.value).toBe(1); + expect(resolution.weakened).toBeTrue(); + expect(resolution.modifierBreakdown).toEqual([ + { label: 'Heat - Fire Modifier', modifier: 1, weakened: true, kind: 'heat' } + ]); }); it('preserves named state and equipment adjustment sources', () => { const resolution = CORE_2026_GAME_RULES.resolveToHit({ subject: mountedWeapon(0), range: 'medium', - stateModifier: -1, - stateModifierBreakdown: [{ label: 'Targeting Computer', modifier: -1 }], + stateModifiers: [{ label: 'Targeting Computer', modifier: -1 }], adjustments: [{ kind: 'add', label: 'Apollo MRM FCS', modifier: -1 }] @@ -528,17 +520,16 @@ describe('game rules', () => { ]); }); - it('uses a named replacement source and rejects an invalid source total', () => { + it('uses named replacement and state sources', () => { const resolution = CORE_2026_GAME_RULES.resolveToHit({ subject: mountedWeapon(1), - stateModifier: 2, - stateModifierBreakdown: [{ label: 'Wrong', modifier: 1 }], + stateModifiers: [{ label: 'State Modifier', modifier: 2 }], adjustments: [{ kind: 'replace-base', value: -2, label: 'Vibroblade' }] }); expect(resolution.modifierBreakdown).toEqual([ { label: 'Vibroblade', modifier: -2 }, - { label: 'Hit Modifier', modifier: 2 } + { label: 'State Modifier', modifier: 2 } ]); }); @@ -569,11 +560,11 @@ describe('game rules', () => { expect(CORE_2026_GAME_RULES.resolveToHit({ subject: weapon }).value).toBeNull(); expect(CORE_2026_GAME_RULES.resolveToHit({ subject: weapon, - adjustments: [{ kind: 'replace-base', value: -2 }] + adjustments: [{ kind: 'replace-base', value: -2, label: 'No-Range Override' }] }).value).toBe(-2); expect(CORE_2026_GAME_RULES.resolveToHit({ subject: mountedWeapon(-2), adjustments: [{ kind: 'unsupported' }] }).value).toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/src/app/models/rules/game-rules.ts b/src/app/models/rules/game-rules.ts index 7c4180479..3ead66503 100644 --- a/src/app/models/rules/game-rules.ts +++ b/src/app/models/rules/game-rules.ts @@ -22,15 +22,14 @@ export interface ToHitModifierBreakdownEntry { } export type ToHitAdjustment = - | { readonly kind: 'replace-base'; readonly value: number | readonly number[]; readonly label?: string } - | { readonly kind: 'add'; readonly modifier: number; readonly label?: string; readonly weakened?: boolean } + | { readonly kind: 'replace-base'; readonly value: number | readonly number[]; readonly label: string } + | { readonly kind: 'add'; readonly modifier: number; readonly label: string; readonly weakened?: boolean } | { readonly kind: 'unsupported' }; export interface ToHitRequest { subject: Equipment | MountedEquipment; range?: RangeBrackets | null; - stateModifier?: number; - stateModifierBreakdown?: readonly ToHitModifierBreakdownEntry[]; + stateModifiers?: readonly ToHitModifierBreakdownEntry[]; adjustments?: readonly ToHitAdjustment[]; } @@ -92,15 +91,6 @@ const TO_HIT_MODIFIER_RANGE_INDEX: Record = { }; const BASE_HIT_MODIFIER_LABEL = 'Base Hit Modifier'; -export function validatedToHitModifierBreakdown( - modifier: number, - breakdown: readonly ToHitModifierBreakdownEntry[] | undefined, - fallbackLabel = 'Hit Modifier' -): ToHitModifierBreakdownEntry[] { - if (breakdown?.reduce((total, entry) => total + entry.modifier, 0) === modifier) return [...breakdown]; - return modifier === 0 ? [] : [{ label: fallbackLabel, modifier }]; -} - export function separateHeatFireModifier(resolution: ToHitResolution): ToHitHeatSeparation { const heatFireModifier = resolution.modifierBreakdown.reduce( (total, entry) => total + (entry.kind === 'heat' ? entry.modifier : 0), @@ -139,13 +129,12 @@ export abstract class CBTGameRules { const replacement = adjustments.find(adjustment => adjustment.kind === 'replace-base'); const hasBaseReplacement = replacement !== undefined; if (unsupported || (entry && !this.supportsToHit(entry) && !hasBaseReplacement)) return emptyToHitResolution(); - const stateModifier = request.stateModifier ?? 0; - const stateBreakdown = validatedToHitModifierBreakdown(stateModifier, request.stateModifierBreakdown); + const stateBreakdown = [...(request.stateModifiers ?? [])]; const adjustmentBreakdowns = adjustments .filter((adjustment): adjustment is Extract => adjustment.kind === 'add') .filter(adjustment => adjustment.modifier !== 0 || adjustment.weakened !== undefined) .map(({ label, modifier, weakened }) => ({ - label: label ?? 'Hit Modifier', + label, modifier, ...(weakened !== undefined && { weakened }) })); @@ -226,7 +215,7 @@ export abstract class CBTGameRules { adjustmentBreakdowns: readonly ToHitModifierBreakdownEntry[], rulesProfile: readonly number[] = baseProfile ): ToHitResolution { - const stateModifier = request.stateModifier ?? 0; + const stateModifier = stateBreakdown.reduce((total, entry) => total + entry.modifier, 0); const adjustmentModifier = adjustments.reduce( (total, adjustment) => total + (adjustment.kind === 'add' ? adjustment.modifier : 0), 0 @@ -375,7 +364,7 @@ export class TWGameRules extends CBTGameRules { private calculateGuidedAmmoBV(unit: CBTForceUnit): number { if (!unit.isLoaded()) return 0; const launchers = unit.getInventory().filter(entry => - entry.equipment instanceof WeaponEquipment && !unit.isEquipmentUnavailable(entry)); + entry.equipment instanceof WeaponEquipment && unit.isEquipmentOperational(entry)); if (launchers.length === 0) return 0; if (unit.getUnit().type === 'Mek') { @@ -383,7 +372,7 @@ export class TWGameRules extends CBTGameRules { const ammo = crit.eq; if (!(ammo instanceof AmmoEquipment) || !isTagGuidedAmmo(ammo) - || unit.isEquipmentUnavailable(crit) + || !unit.isEquipmentOperational(crit) || !hasUsableAmmo(crit.totalAmmo, crit.consumed) || !hasCompatibleLauncher(ammo, launchers) || !ammo.hasFixedBV()) return total; @@ -395,7 +384,7 @@ export class TWGameRules extends CBTGameRules { const ammo = resolveMountedAmmo(unit, mount); if (!(ammo instanceof AmmoEquipment) || !isTagGuidedAmmo(ammo) - || unit.isEquipmentUnavailable(mount) + || !unit.isEquipmentOperational(mount) || !hasUsableAmmo(mount.totalAmmo, mount.consumed) || !hasCompatibleLauncher(ammo, launchers) || !ammo.hasFixedBV()) return total; @@ -446,4 +435,4 @@ function resolveMountedAmmo(unit: CBTForceUnit, mount: MountedEquipment): AmmoEq ? unit.getEquipmentRegistry().findEquipment(mount.ammo) : null; return selectedAmmo instanceof AmmoEquipment ? selectedAmmo : mount.equipment; -} \ No newline at end of file +} diff --git a/src/app/models/rules/infantry-rules.spec.ts b/src/app/models/rules/infantry-rules.spec.ts index e6a69a189..dc2e793f0 100644 --- a/src/app/models/rules/infantry-rules.spec.ts +++ b/src/app/models/rules/infantry-rules.spec.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import type { CBTForceUnit } from '../cbt-force-unit.model'; +import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; import { AmmoEquipment, WeaponEquipment } from '../equipment.model'; import { MountedAmmo, MountedEquipment } from '../mounted-equipment.model'; import { type LocationData } from '../force-serialization'; @@ -18,7 +18,7 @@ function weapon(id: string): WeaponEquipment { }); } -function createHarness(committedTroopDamage = 7): { rules: InfantryRules; entries: MountedEquipment[]; fieldGunComponent: UnitComponent } { +function createHarness(committedTroopDamage = 7): { unit: CBTForceUnit; rules: InfantryRules; entries: MountedEquipment[]; fieldGunComponent: UnitComponent } { const fieldGunComponent = { id: 'Autocannon/2', q: 3, n: 'AC/2', t: 'B', p: 1, l: 'FGUN', r: '8/16/24', m: '4', d: '2', cw: 6 } as UnitComponent; const unit = { getUnit: () => ({ type: 'Infantry', subtype: 'Mechanized Conventional Infantry', internal: 20, squads: 4, squadSize: 5, comp: [fieldGunComponent] }), @@ -34,48 +34,57 @@ function createHarness(committedTroopDamage = 7): { rules: InfantryRules; entrie equipment: fieldGun, locations: new Set(['FGUN']) })); - return { rules: new InfantryRules(unit), entries, fieldGunComponent }; + const rules = new InfantryRules(unit); + Object.assign(unit, { + rules, + getEquipmentStatus: (entry: MountedEquipment) => entry.committedDestroyed() ? 'destroyed' : 'available', + isEquipmentOperational: (entry: MountedEquipment) => !entry.committedDestroyed(), + canPerformEquipmentAction: (entry: MountedEquipment, action: EquipmentAction) => + !entry.committedDestroyed() && rules.canPerformEquipmentAction(entry, action), + }); + return { unit, rules, entries, fieldGunComponent }; } describe('InfantryRules', () => { it('disables field-gun inventory entries beyond the functional crew count', () => { - const { rules, entries, fieldGunComponent } = createHarness(); + const { unit, rules, entries, fieldGunComponent } = createHarness(); expect(rules.getFieldGunComponent(entries[0])).toBe(fieldGunComponent); expect(rules.getFieldGunFunctionalCount(fieldGunComponent)).toBe(2); - expect(entries.map(entry => rules.getEquipmentStatus(entry))).toEqual(['available', 'available', 'disabled']); + expect(entries.map(entry => unit.getEquipmentStatus(entry))).toEqual(['available', 'available', 'available']); + expect(entries.map(entry => unit.canPerformEquipmentAction(entry, 'fire'))).toEqual([true, true, false]); }); - it('does not mutate derived intrinsic ammo while evaluating Battle Armor destruction', () => { - const weaponEntry = new MountedEquipment({ - owner: null as unknown as CBTForceUnit, + it('does not persist derived Battle Armor destruction into inventory mounts', () => { + let weaponEntry!: MountedEquipment; + let intrinsicAmmo!: MountedAmmo; + const unit = { + getUnit: () => ({ type: 'Infantry', subtype: 'Battle Armor', squadSize: 1 }), + getInventory: () => [weaponEntry, intrinsicAmmo], + isArmorLocCommittedDestroyed: () => true, + isArmorLocDestroyed: () => true, + getCritSlots: () => [], + destroyed: false, + setDestroyed: jasmine.createSpy('setDestroyed'), + } as unknown as CBTForceUnit; + weaponEntry = new MountedEquipment({ + owner: unit, id: 'one-shot', name: 'One-shot Weapon', equipment: weapon('one-shot'), }); - const intrinsicAmmo = new MountedAmmo({ - owner: null as unknown as CBTForceUnit, + intrinsicAmmo = new MountedAmmo({ + owner: unit, id: 'one-shot:intrinsic-one-shot-ammo', name: 'Ammo', equipment: new AmmoEquipment({ id: 'Ammo', name: 'Ammo', type: 'ammo', ammo: { type: 'AC', rackSize: 2 } }), parent: weaponEntry, intrinsicOneShotAmmo: true, }); - const unit = { - getUnit: () => ({ type: 'Infantry', subtype: 'Battle Armor', squadSize: 1 }), - getInventory: () => [weaponEntry, intrinsicAmmo], - isArmorLocCommittedDestroyed: () => true, - isArmorLocDestroyed: () => true, - getCritSlots: () => [], - destroyed: false, - setDestroyed: jasmine.createSpy('setDestroyed'), - } as unknown as CBTForceUnit; - weaponEntry.owner = unit; - intrinsicAmmo.owner = unit; - new InfantryRules(unit).evaluateInventoryDestruction(); + new InfantryRules(unit).evaluateDestroyed(); - expect(weaponEntry.committedDestroyed()).toBeTrue(); + expect(weaponEntry.committedDestroyed()).toBeFalse(); expect(intrinsicAmmo.committedDestroyed()).toBeFalse(); }); }); \ No newline at end of file diff --git a/src/app/models/rules/infantry-rules.ts b/src/app/models/rules/infantry-rules.ts index bba0b5dd9..55d150664 100644 --- a/src/app/models/rules/infantry-rules.ts +++ b/src/app/models/rules/infantry-rules.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import type { CBTForceUnit } from '../cbt-force-unit.model'; +import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; import { WeaponEquipment } from '../equipment.model'; import type { MountedEquipment } from '../mounted-equipment.model'; import { parseInventoryComponentReference } from '../inventory-component-reference.model'; @@ -10,7 +10,6 @@ import type { MotiveModes } from '../motiveModes.model'; import { getTargetUnitTypeModifier } from '../target-number-calculator.model'; import type { TurnState } from '../turn-state.model'; import type { UnitComponent } from '../units.model'; -import type { MountedEquipmentStatus } from './unit-type-rules'; import { UnitTypeRulesBase, type UnitModifierBreakdownEntry } from './unit-type-rules'; export const FIELD_GUN_LOCATION = 'FGUN'; @@ -21,13 +20,15 @@ export const FIELD_GUN_LOCATION = 'FGUN'; */ export class InfantryRules extends UnitTypeRulesBase { + override canPerformEquipmentAction(entry: MountedEquipment, action: EquipmentAction): boolean { + return action !== 'fire' || !this.isInfantryFieldGunEntryDisabled(entry); + } + constructor(unit: CBTForceUnit) { super(unit); } evaluateDestroyed(): void { - this.evaluateInventoryDestruction(); - let allDestroyed = true; // Unit destroyed when all troop armor+internal locations are committed-destroyed. @@ -51,32 +52,6 @@ export class InfantryRules extends UnitTypeRulesBase { } } - /** Mark inventory entries as destroyed when the T1 armor location is gone. */ - evaluateInventoryDestruction(): void { - const squadSize = this.unit.getUnit().squadSize ?? 1; - let allSquadsDestroyed = true; - for (let i = 1; i <= squadSize; i++) { - if (!this.unit.isArmorLocCommittedDestroyed(`T${i}`)) { - allSquadsDestroyed = false; - break; - } - } - const t1Destroyed = this.unit.isArmorLocDestroyed('T1'); - for (const entry of this.unit.getInventory()) { - // These mounts are derived runtime ammo records. Their parent weapon - // owns availability and is evaluated separately by ammo controls. - if (entry.intrinsicOneShotAmmo) continue; - if (!entry.equipment) continue; - entry.setCommittedDestroyed(allSquadsDestroyed); - if (allSquadsDestroyed) continue; - - // TODO: not working, locations is empty for Infantry!!!! FIX ME! - if (entry.locations?.has('SSW')) { - entry.setCommittedDestroyed(t1Destroyed); - } - } - } - protected override getTargetUnitTypeModifierBreakdown(_turnState: TurnState): UnitModifierBreakdownEntry[] { const baseUnit = this.unit.getUnit(); if (baseUnit.subtype !== 'Battle Armor') return []; @@ -88,12 +63,6 @@ export class InfantryRules extends UnitTypeRulesBase { return null; } - override getEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus { - const availability = super.getEquipmentStatus(entry); - if (availability !== 'available') return availability; - return this.isInfantryFieldGunEntryDisabled(entry) ? 'disabled' : 'available'; - } - isInfantryFieldGunEntryDisabled(entry: MountedEquipment): boolean { const componentRef = parseInventoryComponentReference(entry.id); const component = this.getFieldGunComponent(entry); diff --git a/src/app/models/rules/mek-rules.spec.ts b/src/app/models/rules/mek-rules.spec.ts index 91e2f7149..3759d8577 100644 --- a/src/app/models/rules/mek-rules.spec.ts +++ b/src/app/models/rules/mek-rules.spec.ts @@ -16,7 +16,8 @@ import { DataService } from '../../services/data.service'; import { EquipmentInteractionRegistryService } from '../../services/equipment-interaction-registry.service'; import { UnitInitializerService } from '../../services/unit-initializer.service'; import { createEmptyUnit } from '../../testing/unit-test-helpers'; -import { type MountedEquipmentToHit } from './unit-type-rules'; +import { type ToHitModifierBreakdownEntry } from './game-rules'; +import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from './unit-type-rules'; import { MekRules } from './mek-rules'; import { MascHandler, MASC_ACTIVE_STATE_KEY } from '../../equipment-handlers/masc.handler'; import { HAG_FLAK_MODE, HAG_STANDARD_MODE, HagHandler } from '../../equipment-handlers/hag.handler'; @@ -24,7 +25,7 @@ import { INVENTORY_CONTROL_MODE_STATE } from '../../utils/inventory-control.util import { OptionsService } from '../../services/options.service'; import { TWMekRules } from './tw-rules'; import { VIBROBLADE_MODE_STATE, VIBROBLADE_ON_MODE, VibrobladeHandler } from '../../equipment-handlers/vibroblade.handler'; -import { PPC_CAPACITOR_CHARGED_STATE, PPC_CAPACITOR_STATE_KEY, PpcCapacitorHandler } from '../../equipment-handlers/ppc-capacitor.handler'; +import { PPC_CAPACITOR_CHARGED_STATE, PPC_CAPACITOR_CHARGING_STATE, PPC_CAPACITOR_STATE_KEY, PpcCapacitorHandler } from '../../equipment-handlers/ppc-capacitor.handler'; import { EquipmentFlag } from '../equipment-flags.type'; class TestCBTForce extends CBTForce { @@ -37,8 +38,12 @@ let unitInitializer: UnitInitializerService; let injector: Injector; let optionsService: OptionsService; -function hasWeakenedHitModifier(state: MountedEquipmentToHit): boolean { - return state.modifiers.some(modifier => modifier.weakened === true); +function toHitModifierTotal(modifiers: readonly ToHitModifierBreakdownEntry[]): number { + return modifiers.reduce((total, modifier) => total + modifier.modifier, 0); +} + +function hasWeakenedHitModifier(modifiers: readonly ToHitModifierBreakdownEntry[]): boolean { + return modifiers.some(modifier => modifier.weakened === true); } function createRulesHarness(options: { @@ -70,6 +75,38 @@ function createCommittedLocationState(committedDestroyedLocations: string[] = [] }, {}); } +function normalizeGeneratedCriticalSlots(criticalSlots: readonly CriticalSlot[]): CriticalSlot[] { + const usedSlotsByLocation = new Map>(); + const idCounts = new Map(); + + for (const criticalSlot of criticalSlots) { + if (criticalSlot.loc && criticalSlot.slot !== undefined) { + const usedSlots = usedSlotsByLocation.get(criticalSlot.loc) ?? new Set(); + usedSlots.add(criticalSlot.slot); + usedSlotsByLocation.set(criticalSlot.loc, usedSlots); + } + } + + return criticalSlots.map(criticalSlot => { + let normalized = criticalSlot; + if (criticalSlot.loc && criticalSlot.slot === undefined) { + const usedSlots = usedSlotsByLocation.get(criticalSlot.loc) ?? new Set(); + let slot = 0; + while (usedSlots.has(slot)) slot++; + usedSlots.add(slot); + usedSlotsByLocation.set(criticalSlot.loc, usedSlots); + normalized = { ...normalized, slot }; + } + + if (!normalized.loc || normalized.slot === undefined) { + const count = idCounts.get(normalized.id) ?? 0; + idCounts.set(normalized.id, count + 1); + if (count > 0) normalized = { ...normalized, id: `${normalized.id}-${count}` }; + } + return normalized; + }); +} + function createForceUnitHarness(options: { crewStates?: Exclude[]; crewHits?: number[]; @@ -123,7 +160,21 @@ function createForceUnitHarness(options: { forceUnit.setLocations(options.locationState ?? createCommittedLocationState(options.committedDestroyedLocations), true); if (options.critSlots) { - forceUnit.writeCrits(options.critSlots); + const targetingComputer = miscEquipment('ISTargeting Computer', 'Targeting Computer', ['F_TARGETING_COMPUTER']); + const criticalSlots = normalizeGeneratedCriticalSlots(options.critSlots).map(slot => + slot.name === 'Targeting Computer' ? { ...slot, eq: targetingComputer } : slot + ); + forceUnit.writeCrits(criticalSlots); + const targetingComputerSlots = criticalSlots.filter(slot => slot.eq === targetingComputer); + if (targetingComputerSlots.length > 0) { + forceUnit.setInventory([new MountedEquipment({ + owner: forceUnit, + id: targetingComputer.id, + name: targetingComputer.id, + equipment: targetingComputer, + critSlots: targetingComputerSlots, + })]); + } } crewStates.forEach((state, index) => forceUnit.getCrewMember(index).setState(state)); crewHits.forEach((hits, index) => forceUnit.getCrewMember(index).setHits(hits)); @@ -183,11 +234,17 @@ function heavyDutyGyroCrit(index: number, destroyed = true): CriticalSlot { } function legActuatorCrit(id: string, name: string, loc: string, destroyed = true): CriticalSlot { + const slotByActuator: Record = { + hip: 0, + 'upper-leg': 1, + 'lower-leg': 2, + foot: 3, + }; return { id, name, loc, - slot: 0, + slot: slotByActuator[id], destroyed: destroyed ? 1 : undefined, }; } @@ -399,22 +456,135 @@ describe('MekRules', () => { expect(rules.hasComputedCondition('abandoned')).toBeFalse(); }); - it('applies a functional targeting computer only to eligible direct-fire weapons', () => { + it('removes a broken targeting computer modifier from direct-fire weapons at every range', () => { const activeForceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer', false)] }); const destroyedForceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer')] }); + const ranges = ['short', 'medium', 'long'] as const; + + const activeEntry = directFireWeaponEntry(activeForceUnit); + const destroyedEntry = directFireWeaponEntry(destroyedForceUnit); + const activeModifiers = activeForceUnit.rules.getEquipmentToHitModifiers(activeEntry); + const destroyedModifiers = destroyedForceUnit.rules.getEquipmentToHitModifiers(destroyedEntry); + const ineligibleModifiers = destroyedForceUnit.rules.getEquipmentToHitModifiers(directFireWeaponEntry(destroyedForceUnit, ['F_TASER'])); + const destroyedTargetingComputer = destroyedForceUnit.getMountedEquipmentByFlag('F_TARGETING_COMPUTER')[0]; + + expect(destroyedTargetingComputer).toBeDefined(); + expect(destroyedForceUnit.isEquipmentOperational(destroyedTargetingComputer)).toBeFalse(); + expect(toHitModifierTotal(activeModifiers)).toBe(-1); + expect(toHitModifierTotal(destroyedModifiers)).toBe(0); + expect(toHitModifierTotal(ineligibleModifiers)).toBe(0); + expect(hasWeakenedHitModifier(activeModifiers)).toBeFalse(); + expect(hasWeakenedHitModifier(destroyedModifiers)).toBeTrue(); + expect(hasWeakenedHitModifier(ineligibleModifiers)).toBeFalse(); + expect(destroyedModifiers).toEqual([ + { label: 'Targeting Computer Destroyed', modifier: 0, weakened: true } + ]); - const activeState = activeForceUnit.rules.getEquipmentToHit(directFireWeaponEntry(activeForceUnit)); - const destroyedState = destroyedForceUnit.rules.getEquipmentToHit(directFireWeaponEntry(destroyedForceUnit)); - const ineligibleState = destroyedForceUnit.rules.getEquipmentToHit(directFireWeaponEntry(destroyedForceUnit, ['F_TASER'])); - expect((activeState).modifier).toBe(-1); - expect((destroyedState).modifier).toBe(0); - expect((ineligibleState).modifier).toBe(0); - expect(hasWeakenedHitModifier(activeState)).toBeFalse(); - expect(hasWeakenedHitModifier(destroyedState)).toBeTrue(); - expect(hasWeakenedHitModifier(ineligibleState)).toBeFalse(); + for (const range of ranges) { + const activeResolution = activeForceUnit.gameRules.resolveToHit({ + subject: activeEntry, + range, + stateModifiers: activeModifiers, + }); + const destroyedResolution = destroyedForceUnit.gameRules.resolveToHit({ + subject: destroyedEntry, + range, + stateModifiers: destroyedModifiers, + }); + + expect(activeResolution.value) + .withContext(`functional targeting computer at ${range} range`) + .toBe(-1); + expect(activeResolution.weakened) + .withContext(`functional targeting computer weakened state at ${range} range`) + .toBeFalse(); + expect(destroyedResolution.value) + .withContext(`destroyed targeting computer at ${range} range`) + .toBe(0); + expect(destroyedResolution.weakened) + .withContext(`destroyed targeting computer weakened state at ${range} range`) + .toBeTrue(); + expect(destroyedResolution.modifierBreakdown) + .withContext(`destroyed targeting computer breakdown at ${range} range`) + .toEqual([{ label: 'Targeting Computer Destroyed', modifier: 0, weakened: true }]); + } }); - it('does not cycle while resolving a charged PPC capacitor weapon state', () => { + it('labels a disabled targeting computer as disabled instead of destroyed', () => { + const forceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer', false)] }); + const targetingComputer = forceUnit.getMountedEquipmentByFlag('F_TARGETING_COMPUTER')[0]; + targetingComputer.states.set(ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE); + forceUnit.setInventoryEntry(targetingComputer); + + expect(forceUnit.getEquipmentStatus(targetingComputer)).toBe('disabled'); + expect(forceUnit.rules.getEquipmentToHitModifiers(directFireWeaponEntry(forceUnit))).toEqual([{ + label: 'Targeting Computer Disabled', + modifier: 0, + weakened: true, + }]); + }); + + it('keeps Mek sensor and actuator failures in action policy instead of equipment status', () => { + const sensorDisabledUnit = createForceUnitHarness({ + critSlots: [ + { ...crit('Sensor'), id: 'head-sensor-1', loc: 'HD', slot: 0 }, + { ...crit('Sensor'), id: 'head-sensor-2', loc: 'HD', slot: 1 }, + ], + internalLocations: ['HD', 'LA', 'RA', 'LL', 'RL'], + }); + const weapon = directFireWeaponEntry(sensorDisabledUnit); + + expect(sensorDisabledUnit.getEquipmentStatus(weapon)).toBe('available'); + expect(sensorDisabledUnit.canPerformEquipmentAction(weapon, 'fire')).toBeFalse(); + + const actuatorDisabledUnit = createForceUnitHarness({ + critSlots: armCritSlots('LA').map(slot => slot.name === 'Shoulder' + ? { ...slot, destroyed: 1 } + : slot), + internalLocations: ['LA', 'RA', 'LL', 'RL'], + }); + const punch = punchEntry(actuatorDisabledUnit); + const club = new MountedEquipment({ + owner: actuatorDisabledUnit, + id: 'club', + name: 'club', + intrinsicPhysicalAttack: true, + }); + const hatchet = new MountedEquipment({ + owner: actuatorDisabledUnit, + id: 'hatchet@LA', + name: 'Hatchet', + equipment: miscEquipment('Hatchet', 'Hatchet', ['F_HAND_WEAPON']), + locations: new Set(['LA']), + }); + + expect(actuatorDisabledUnit.getEquipmentStatus(punch)).toBe('available'); + expect(actuatorDisabledUnit.getEquipmentStatus(club)).toBe('available'); + expect(actuatorDisabledUnit.getEquipmentStatus(hatchet)).toBe('available'); + expect(actuatorDisabledUnit.canPerformEquipmentAction(punch, 'physical-attack')).toBeFalse(); + expect(actuatorDisabledUnit.canPerformEquipmentAction(club, 'physical-attack')).toBeFalse(); + expect(actuatorDisabledUnit.canPerformEquipmentAction(hatchet, 'physical-attack')).toBeFalse(); + }); + + it('removes the targeting computer bonus when any critical in its installation is destroyed', () => { + const forceUnit = createForceUnitHarness({ + critSlots: [ + { ...crit('Targeting Computer', false), id: 'targeting-computer-1', loc: 'LT', slot: 0 }, + { ...crit('Targeting Computer'), id: 'targeting-computer-2', loc: 'LT', slot: 1 }, + { ...crit('Targeting Computer', false), id: 'targeting-computer-3', loc: 'RT', slot: 0 }, + ], + internalLocations: ['LT', 'RT'], + }); + + const modifiers = forceUnit.rules.getEquipmentToHitModifiers(directFireWeaponEntry(forceUnit)); + + expect(toHitModifierTotal(modifiers)).toBe(0); + expect(modifiers).toEqual([ + { label: 'Targeting Computer Destroyed', modifier: 0, weakened: true } + ]); + }); + + it('resolves stored PPC capacitor states without cycling', () => { TestBed.inject(EquipmentInteractionRegistryService).getRegistry().register(new PpcCapacitorHandler()); const forceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer', false)] }); const capacitor = new MountedEquipment({ @@ -427,7 +597,6 @@ describe('MekRules', () => { type: 'misc', flags: ['F_WEAPON_ENHANCEMENT', 'F_PPC_CAPACITOR'], }), - states: new Map([[PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE]]), }); const weapon = new MountedWeapon({ owner: forceUnit, @@ -442,47 +611,95 @@ describe('MekRules', () => { }), }); weapon.linkedWith = [capacitor]; + forceUnit.setInventory([...forceUnit.getInventory(), weapon, capacitor]); + + const stored = () => ({ + weapon: forceUnit.getInventory().find(entry => entry.id === weapon.id) as MountedWeapon, + capacitor: forceUnit.getInventory().find(entry => entry.id === capacitor.id)!, + }); + const expectTargetingComputerApplies = (context: string) => { + const current = stored(); + expect(current.weapon).withContext(`${context}: stored weapon`).toBeDefined(); + expect(current.capacitor).withContext(`${context}: stored capacitor`).toBeDefined(); + expect(current.weapon.linkedWith).withContext(`${context}: stored link`).toContain(current.capacitor); + expect(() => forceUnit.rules.getEquipmentToHitModifiers(current.weapon)) + .withContext(`${context}: no query cycle`) + .not.toThrow(); + expect(forceUnit.rules.getEquipmentToHitModifiers(current.weapon)) + .withContext(`${context}: targeting computer modifier`) + .toContain(jasmine.objectContaining({ label: 'Targeting Computer', modifier: -1 })); + }; - expect(() => weapon.owner.rules.getEquipmentToHit(weapon)).not.toThrow(); - expect(weapon.owner.rules.getEquipmentToHit(weapon).modifiers).toContain( - jasmine.objectContaining({ label: 'Targeting Computer', modifier: -1 }) - ); + expectTargetingComputerApplies('discharged'); + + let current = stored(); + current.capacitor.setState(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGING_STATE); + forceUnit.setInventoryEntry(current.capacitor); + expectTargetingComputerApplies('charging'); + + current = stored(); + current.capacitor.setState(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); + forceUnit.setInventoryEntry(current.capacitor); + expectTargetingComputerApplies('charged'); + + current = stored(); + current.capacitor.setCommittedDestroyed(true); + forceUnit.setInventoryEntry(current.capacitor); + expectTargetingComputerApplies('unavailable'); }); - it('stacks a targeting computer with each range-specific VSP laser modifier', () => { + it('removes a broken targeting computer modifier from each VSP laser range', () => { const activeForceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer', false)] }); const destroyedForceUnit = createForceUnitHarness({ critSlots: [crit('Targeting Computer')] }); const ranges = [ - { range: 'short' as const, value: -4 }, - { range: 'medium' as const, value: -3 }, - { range: 'long' as const, value: -2 }, + { range: 'short' as const, baseValue: -3, activeValue: -4 }, + { range: 'medium' as const, baseValue: -2, activeValue: -3 }, + { range: 'long' as const, baseValue: -1, activeValue: -2 }, ]; const activeEntry = mediumVspLaserEntry(activeForceUnit); - const activeState = activeForceUnit.rules.getEquipmentToHit(activeEntry); + const activeModifiers = activeForceUnit.rules.getEquipmentToHitModifiers(activeEntry); expect(activeEntry.parent).toBeInstanceOf(MountedWeapon); expect((activeEntry.parent as MountedWeapon).getWeaponTypes()).toContain('P'); - expect((activeState).modifier).toBe(-1); + expect(toHitModifierTotal(activeModifiers)).toBe(-1); for (const expected of ranges) { expect(activeForceUnit.gameRules.resolveToHit({ subject: activeEntry, range: expected.range, - stateModifier: (activeState).modifier, - stateModifierBreakdown: activeState.modifiers, - }).value).withContext(`functional targeting computer at ${expected.range} range`).toBe(expected.value); + stateModifiers: activeModifiers, + }).value).withContext(`functional targeting computer at ${expected.range} range`).toBe(expected.activeValue); } const destroyedEntry = mediumVspLaserEntry(destroyedForceUnit); - const destroyedState = destroyedForceUnit.rules.getEquipmentToHit(destroyedEntry); - expect((destroyedState).modifier).toBe(0); - const destroyedResolution = destroyedForceUnit.gameRules.resolveToHit({ - subject: destroyedEntry, - range: 'short', - stateModifier: (destroyedState).modifier, - stateModifierBreakdown: destroyedState.modifiers, - }); - expect(destroyedResolution.value).toBe(-3); - expect(destroyedResolution.weakened).toBeTrue(); + const destroyedModifiers = destroyedForceUnit.rules.getEquipmentToHitModifiers(destroyedEntry); + const destroyedTargetingComputer = destroyedForceUnit.getMountedEquipmentByFlag('F_TARGETING_COMPUTER')[0]; + + expect(destroyedTargetingComputer).toBeDefined(); + expect(destroyedForceUnit.isEquipmentOperational(destroyedTargetingComputer)).toBeFalse(); + expect(toHitModifierTotal(destroyedModifiers)).toBe(0); + expect(destroyedModifiers).toEqual([ + { label: 'Targeting Computer Destroyed', modifier: 0, weakened: true } + ]); + for (const expected of ranges) { + const destroyedResolution = destroyedForceUnit.gameRules.resolveToHit({ + subject: destroyedEntry, + range: expected.range, + stateModifiers: destroyedModifiers, + }); + + expect(destroyedResolution.value) + .withContext(`destroyed targeting computer at ${expected.range} VSP range`) + .toBe(expected.baseValue); + expect(destroyedResolution.weakened) + .withContext(`destroyed targeting computer weakened state at ${expected.range} VSP range`) + .toBeTrue(); + expect(destroyedResolution.modifierBreakdown) + .withContext(`destroyed targeting computer breakdown at ${expected.range} VSP range`) + .toEqual([ + { label: 'Base Hit Modifier', modifier: expected.baseValue }, + { label: 'Targeting Computer Destroyed', modifier: 0, weakened: true }, + ]); + } }); it('applies HAG mode and targeting-computer modifiers without stacking them', () => { @@ -502,12 +719,11 @@ describe('MekRules', () => { const forceUnit = createForceUnitHarness({ critSlots }); const entry = hagWeaponEntry(forceUnit, scenario.mode); const rules = forceUnit.getInventoryControlRules(); - const state = forceUnit.rules.getEquipmentToHit(entry); - const effectiveTypes = rules.applyWeaponTypes?.(entry, new Set(entry.getWeaponTypes())) ?? new Set(entry.getWeaponTypes()); + const stateModifiers = forceUnit.rules.getEquipmentToHitModifiers(entry); + const effectiveTypes = forceUnit.getEffectiveWeaponTypes(entry); const resolution = forceUnit.gameRules.resolveToHit({ subject: entry, - stateModifier: (state).modifier, - stateModifierBreakdown: state.modifiers, + stateModifiers, adjustments: rules.resolveToHitAdjustments?.(entry) }); @@ -541,14 +757,14 @@ describe('MekRules', () => { locations: new Set(['LA']), }); - const activePunch = activeForceUnit.rules.getEquipmentToHit(punch(activeForceUnit)); - const destroyedPunch = destroyedForceUnit.rules.getEquipmentToHit(punch(destroyedForceUnit)); - const activeSword = activeForceUnit.rules.getEquipmentToHit(sword(activeForceUnit)); - const destroyedSword = destroyedForceUnit.rules.getEquipmentToHit(sword(destroyedForceUnit)); - expect((activePunch).modifier).toBe(-1); - expect((destroyedPunch).modifier).toBe(0); - expect((activeSword).modifier).toBe(-1); - expect((destroyedSword).modifier).toBe(0); + const activePunch = activeForceUnit.rules.getEquipmentToHitModifiers(punch(activeForceUnit)); + const destroyedPunch = destroyedForceUnit.rules.getEquipmentToHitModifiers(punch(destroyedForceUnit)); + const activeSword = activeForceUnit.rules.getEquipmentToHitModifiers(sword(activeForceUnit)); + const destroyedSword = destroyedForceUnit.rules.getEquipmentToHitModifiers(sword(destroyedForceUnit)); + expect(toHitModifierTotal(activePunch)).toBe(-1); + expect(toHitModifierTotal(destroyedPunch)).toBe(0); + expect(toHitModifierTotal(activeSword)).toBe(-1); + expect(toHitModifierTotal(destroyedSword)).toBe(0); expect(hasWeakenedHitModifier(activePunch)).toBeFalse(); expect(hasWeakenedHitModifier(destroyedPunch)).toBeTrue(); expect(hasWeakenedHitModifier(activeSword)).toBeFalse(); @@ -572,15 +788,13 @@ describe('MekRules', () => { intrinsicPhysicalAttack: true, }); - const punchState = forceUnit.rules.getEquipmentToHit(punch); - expect((punchState).modifier).toBe(5); - expect(punchState).toEqual(jasmine.objectContaining({ - modifiers: [ - { label: 'Hand Actuator Destroyed (LA)', modifier: 1, weakened: true }, - { label: 'Upper Arm Actuator Destroyed (LA)', modifier: 2, weakened: true }, - { label: 'Lower Arm Actuator Destroyed (LA)', modifier: 2, weakened: true } - ] - })); + const punchModifiers = forceUnit.rules.getEquipmentToHitModifiers(punch); + expect(toHitModifierTotal(punchModifiers)).toBe(5); + expect(punchModifiers).toEqual([ + { label: 'Hand Actuator Destroyed (LA)', modifier: 1, weakened: true }, + { label: 'Upper Arm Actuator Destroyed (LA)', modifier: 2, weakened: true }, + { label: 'Lower Arm Actuator Destroyed (LA)', modifier: 2, weakened: true } + ]); }); it('applies missing punch actuator modifiers as design penalties without weakening', () => { @@ -613,17 +827,14 @@ describe('MekRules', () => { internalLocations: ['LA', 'RA', 'LL', 'RL'], }); const punch = punchEntry(forceUnit); - const state = forceUnit.rules.getEquipmentToHit(punch); + const stateModifiers = forceUnit.rules.getEquipmentToHitModifiers(punch); const resolution = forceUnit.gameRules.resolveToHit({ subject: punch, - stateModifier: (state).modifier, - stateModifierBreakdown: state.modifiers, + stateModifiers, }); const rulesBase = rulesId === 'core2026' ? -1 : 0; - expect(state).withContext(`${rulesId}: ${scenario.label}`).toEqual(jasmine.objectContaining({ - modifiers: scenario.breakdown, - })); + expect(stateModifiers).withContext(`${rulesId}: ${scenario.label}`).toEqual(scenario.breakdown); expect(resolution.value).withContext(`${rulesId}: ${scenario.label} resolved modifier`) .toBe(rulesBase + scenario.hitMod); expect(resolution.weakened).withContext(`${rulesId}: ${scenario.label} resolved weakening`).toBeFalse(); @@ -643,7 +854,7 @@ describe('MekRules', () => { expect((missingLowerArmUnit.rules as MekRules).computeMeleeDamage(3, 'punch', 'LA')).toEqual({ damage: 3, maxDamage: 3 }); expect((destroyedLowerArmUnit.rules as MekRules).computeMeleeDamage(6, 'punch', 'LA')).toEqual({ damage: 3, maxDamage: 3 }); - expect((destroyedLowerArmUnit.rules.getEquipmentToHit(punchEntry(destroyedLowerArmUnit))).modifier) + expect(toHitModifierTotal(destroyedLowerArmUnit.rules.getEquipmentToHitModifiers(punchEntry(destroyedLowerArmUnit)))) .toBe(2); }); @@ -658,14 +869,12 @@ describe('MekRules', () => { }); const push = new MountedEquipment({ owner: forceUnit, id: 'push', name: 'push', intrinsicPhysicalAttack: true }); - const pushState = forceUnit.rules.getEquipmentToHit(push); - expect((pushState).modifier).toBe(1); - expect(pushState).toEqual(jasmine.objectContaining({ - modifiers: [ - { label: 'Shoulder Destroyed (LA)', modifier: 2, weakened: true }, - { label: 'Paired Arm AES', modifier: -1 } - ] - })); + const pushModifiers = forceUnit.rules.getEquipmentToHitModifiers(push); + expect(toHitModifierTotal(pushModifiers)).toBe(1); + expect(pushModifiers).toEqual([ + { label: 'Shoulder Destroyed (LA)', modifier: 2, weakened: true }, + { label: 'Paired Arm AES', modifier: -1 } + ]); }); it('identifies aggregate leg actuator, foot, and AES modifiers for kicks', () => { @@ -681,15 +890,13 @@ describe('MekRules', () => { }); const kick = new MountedEquipment({ owner: forceUnit, id: 'kick', name: 'kick', intrinsicPhysicalAttack: true }); - const kickState = forceUnit.rules.getEquipmentToHit(kick); - expect((kickState).modifier).toBe(4); - expect(kickState).toEqual(jasmine.objectContaining({ - modifiers: [ - { label: 'Leg Actuators Destroyed ×2', modifier: 4, weakened: true }, - { label: 'Foot Actuator Destroyed', modifier: 1, weakened: true }, - { label: 'Leg AES', modifier: -1 } - ] - })); + const kickModifiers = forceUnit.rules.getEquipmentToHitModifiers(kick); + expect(toHitModifierTotal(kickModifiers)).toBe(4); + expect(kickModifiers).toEqual([ + { label: 'Leg Actuators Destroyed ×2', modifier: 4, weakened: true }, + { label: 'Foot Actuator Destroyed', modifier: 1, weakened: true }, + { label: 'Leg AES', modifier: -1 } + ]); }); it('identifies mounted physical weapon actuator modifiers without a generic fallback', () => { @@ -709,15 +916,13 @@ describe('MekRules', () => { locations: new Set(['LA']), }); - const swordState = forceUnit.rules.getEquipmentToHit(sword); - expect((swordState).modifier).toBe(3); - expect(swordState).toEqual(jasmine.objectContaining({ - modifiers: [ - { label: 'Upper Arm Actuator Destroyed (LA)', modifier: 2, weakened: true }, - { label: 'Lower Arm Actuator Destroyed (LA)', modifier: 2, weakened: true }, - { label: 'Arm AES (LA)', modifier: -1 } - ] - })); + const swordModifiers = forceUnit.rules.getEquipmentToHitModifiers(sword); + expect(toHitModifierTotal(swordModifiers)).toBe(3); + expect(swordModifiers).toEqual([ + { label: 'Upper Arm Actuator Destroyed (LA)', modifier: 2, weakened: true }, + { label: 'Lower Arm Actuator Destroyed (LA)', modifier: 2, weakened: true }, + { label: 'Arm AES (LA)', modifier: -1 } + ]); }); it('marks paired-arm AES modifiers as weakened when damage removes their attack bonus', () => { @@ -741,12 +946,12 @@ describe('MekRules', () => { intrinsicPhysicalAttack: true, }); - const clubState = forceUnit.rules.getEquipmentToHit(physical('club')); - const pushState = forceUnit.rules.getEquipmentToHit(physical('push')); - expect((clubState).modifier).withContext(`${scenario.label} arm AES for club`).toBe(scenario.club.hitMod); - expect(hasWeakenedHitModifier(clubState)).withContext(`${scenario.label} arm AES for club`).toBe(scenario.club.weakened); - expect((pushState).modifier).withContext(`${scenario.label} arm AES for push`).toBe(scenario.push.hitMod); - expect(hasWeakenedHitModifier(pushState)).withContext(`${scenario.label} arm AES for push`).toBe(scenario.push.weakened); + const clubModifiers = forceUnit.rules.getEquipmentToHitModifiers(physical('club')); + const pushModifiers = forceUnit.rules.getEquipmentToHitModifiers(physical('push')); + expect(toHitModifierTotal(clubModifiers)).withContext(`${scenario.label} arm AES for club`).toBe(scenario.club.hitMod); + expect(hasWeakenedHitModifier(clubModifiers)).withContext(`${scenario.label} arm AES for club`).toBe(scenario.club.weakened); + expect(toHitModifierTotal(pushModifiers)).withContext(`${scenario.label} arm AES for push`).toBe(scenario.push.hitMod); + expect(hasWeakenedHitModifier(pushModifiers)).withContext(`${scenario.label} arm AES for push`).toBe(scenario.push.weakened); } }); @@ -770,9 +975,9 @@ describe('MekRules', () => { intrinsicPhysicalAttack: true, }); - const state = forceUnit.rules.getEquipmentToHit(kick); - expect((state).modifier).withContext(`${scenario.label} leg AES`).toBe(scenario.expected.hitMod); - expect(hasWeakenedHitModifier(state)).withContext(`${scenario.label} leg AES`).toBe(scenario.expected.weakened); + const modifiers = forceUnit.rules.getEquipmentToHitModifiers(kick); + expect(toHitModifierTotal(modifiers)).withContext(`${scenario.label} leg AES`).toBe(scenario.expected.hitMod); + expect(hasWeakenedHitModifier(modifiers)).withContext(`${scenario.label} leg AES`).toBe(scenario.expected.weakened); } }); @@ -867,14 +1072,14 @@ describe('MekRules', () => { expect(forceUnit.rules.applyInventoryControlDisplayEffects(vibroblade, display).damage).toBe('14'); expect(forceUnit.applyInventoryControlDisplayEffects(vibroblade, display, { selectedRange: null, - additionalHitModifier: 0, + hitModifierBreakdown: forceUnit.rules.getEquipmentToHitModifiers(vibroblade), selectedAmmo: null, }).damage).toBe('14 [7]'); vibroblade.states.set(VIBROBLADE_MODE_STATE, VIBROBLADE_ON_MODE); expect(forceUnit.applyInventoryControlDisplayEffects(vibroblade, display, { selectedRange: null, - additionalHitModifier: 0, + hitModifierBreakdown: forceUnit.rules.getEquipmentToHitModifiers(vibroblade), selectedAmmo: null, }).damage).toBe('7'); }); @@ -897,7 +1102,7 @@ describe('MekRules', () => { expect(forceUnit.rules.applyInventoryControlDisplayEffects(vibroblade, display).damage).toBe('5'); expect(forceUnit.applyInventoryControlDisplayEffects(vibroblade, display, { selectedRange: null, - additionalHitModifier: 0, + hitModifierBreakdown: forceUnit.rules.getEquipmentToHitModifiers(vibroblade), selectedAmmo: null, }).damage).toBe('5 [10]'); }); @@ -959,13 +1164,13 @@ describe('MekRules', () => { const rules = forceUnit.rules as MekRules; expect(rules.getBaseGunnerySkill()).toBe(3); - const rangedState = rules.getEquipmentToHit(directFireWeaponEntry(forceUnit)); - expect((rangedState).modifier).toBe(0); - expect(rangedState).toEqual(jasmine.objectContaining({ modifiers: [] })); + const rangedModifiers = rules.getEquipmentToHitModifiers(directFireWeaponEntry(forceUnit)); + expect(toHitModifierTotal(rangedModifiers)).toBe(0); + expect(rangedModifiers).toEqual([]); expect(rules.getBasePilotingSkill()).toBe(5); - const punchState = rules.getEquipmentToHit(punchEntry(forceUnit)); - expect((punchState).modifier).toBe(-1); - expect(punchState).toEqual(jasmine.objectContaining({ modifiers: [{ label: 'Dedicated Pilot', modifier: -1 }] })); + const punchModifiers = rules.getEquipmentToHitModifiers(punchEntry(forceUnit)); + expect(toHitModifierTotal(punchModifiers)).toBe(-1); + expect(punchModifiers).toEqual([{ label: 'Dedicated Pilot', modifier: -1 }]); expect(rules.PSRTargetRoll()).toBe(4); }); @@ -978,11 +1183,11 @@ describe('MekRules', () => { expect(rules.getBaseGunnerySkill()).toBe(5); const ranged = directFireWeaponEntry(forceUnit); - const rangedState = rules.getEquipmentToHit(ranged); - expect((rangedState).modifier).toBe(2); - expect(rangedState).toEqual(jasmine.objectContaining({ - modifiers: [{ label: 'Dedicated Gunnery Officer disabled', modifier: 2, weakened: true }], - })); + const rangedModifiers = rules.getEquipmentToHitModifiers(ranged); + expect(toHitModifierTotal(rangedModifiers)).toBe(2); + expect(rangedModifiers).toEqual([ + { label: 'Dedicated Gunnery Officer disabled', modifier: 2, weakened: true }, + ]); expect(forceUnit.turnState().getAttackModifierBreakdown()).toEqual([]); }); @@ -1002,11 +1207,11 @@ describe('MekRules', () => { const ranged = directFireWeaponEntry(forceUnit); expect(forceUnit.turnState().getAttackModifierBreakdown()).withContext(scenario.context).toEqual([]); - const rangedState = forceUnit.rules.getEquipmentToHit(ranged); - expect((rangedState).modifier).withContext(scenario.context).toBe(scenario.modifier); - expect(rangedState).withContext(scenario.context).toEqual(jasmine.objectContaining({ - modifiers: [{ label: scenario.label, modifier: scenario.modifier, weakened: true }], - })); + const rangedModifiers = forceUnit.rules.getEquipmentToHitModifiers(ranged); + expect(toHitModifierTotal(rangedModifiers)).withContext(scenario.context).toBe(scenario.modifier); + expect(rangedModifiers).withContext(scenario.context).toEqual([ + { label: scenario.label, modifier: scenario.modifier, weakened: true }, + ]); } }); @@ -1018,11 +1223,11 @@ describe('MekRules', () => { const rules = forceUnit.rules as MekRules; expect(rules.getBasePilotingSkill()).toBe(6); - const punchState = rules.getEquipmentToHit(punchEntry(forceUnit)); - expect((punchState).modifier).toBe(2); - expect(punchState).toEqual(jasmine.objectContaining({ - modifiers: [{ label: 'Dedicated Pilot disabled', modifier: 2, weakened: true }], - })); + const punchModifiers = rules.getEquipmentToHitModifiers(punchEntry(forceUnit)); + expect(toHitModifierTotal(punchModifiers)).toBe(2); + expect(punchModifiers).toEqual([ + { label: 'Dedicated Pilot disabled', modifier: 2, weakened: true }, + ]); expect(rules.PSRTargetRoll()).toBe(8); }); @@ -1037,20 +1242,20 @@ describe('MekRules', () => { }); const ranged = new MountedEquipment({ owner: forceUnit, id: 'laser', name: 'Laser' }); - const initialPunchState = forceUnit.rules.getEquipmentToHit(punch); - expect((initialPunchState).modifier).toBe(-1); - expect(initialPunchState).toEqual(jasmine.objectContaining({ modifiers: [{ label: 'Dedicated Pilot', modifier: -1 }] })); - const initialRangedState = forceUnit.rules.getEquipmentToHit(ranged); - expect((initialRangedState).modifier).toBe(0); - expect(initialRangedState).toEqual(jasmine.objectContaining({ modifiers: [] })); + const initialPunchModifiers = forceUnit.rules.getEquipmentToHitModifiers(punch); + expect(toHitModifierTotal(initialPunchModifiers)).toBe(-1); + expect(initialPunchModifiers).toEqual([{ label: 'Dedicated Pilot', modifier: -1 }]); + const initialRangedModifiers = forceUnit.rules.getEquipmentToHitModifiers(ranged); + expect(toHitModifierTotal(initialRangedModifiers)).toBe(0); + expect(initialRangedModifiers).toEqual([]); forceUnit.getCrewMember(0).setState('unconscious'); - const disabledPunchState = forceUnit.rules.getEquipmentToHit(punch); - expect((disabledPunchState).modifier).toBe(2); - expect(disabledPunchState).toEqual(jasmine.objectContaining({ - modifiers: [{ label: 'Dedicated Pilot disabled', modifier: 2, weakened: true }], - })); + const disabledPunchModifiers = forceUnit.rules.getEquipmentToHitModifiers(punch); + expect(toHitModifierTotal(disabledPunchModifiers)).toBe(2); + expect(disabledPunchModifiers).toEqual([ + { label: 'Dedicated Pilot disabled', modifier: 2, weakened: true }, + ]); }); it('includes intact Tripod legs in piloting checks', () => { @@ -1079,18 +1284,15 @@ describe('MekRules', () => { const superheavyPhysical = physical(superheavy); expect(superheavy.rules.PSRModifiers().modifiers.map(modifier => modifier.reason)).not.toContain('Superheavy'); - const superheavyState = superheavy.rules.getEquipmentToHit(superheavyPhysical); - expect((superheavyState).modifier).toBe(1); - expect(superheavyState).toEqual(jasmine.objectContaining({ - modifiers: [{ label: 'Superheavy', modifier: 1 }], - })); + const superheavyModifiers = superheavy.rules.getEquipmentToHitModifiers(superheavyPhysical); + expect(toHitModifierTotal(superheavyModifiers)).toBe(1); + expect(superheavyModifiers).toEqual([{ label: 'Superheavy', modifier: 1 }]); expect(superheavy.gameRules.resolveToHit({ subject: superheavyPhysical, - stateModifier: (superheavyState).modifier, - stateModifierBreakdown: superheavyState.modifiers, + stateModifiers: superheavyModifiers, }).weakened).toBeFalse(); - expect((superheavy.rules.getEquipmentToHit(ranged)).modifier).toBe(0); - expect((assault.rules.getEquipmentToHit(physical(assault))).modifier).toBe(0); + expect(toHitModifierTotal(superheavy.rules.getEquipmentToHitModifiers(ranged))).toBe(0); + expect(toHitModifierTotal(assault.rules.getEquipmentToHitModifiers(physical(assault)))).toBe(0); }); it('does not apply gunnery modifiers to non-attack equipment', () => { @@ -1103,9 +1305,9 @@ describe('MekRules', () => { equipment: miscEquipment('Utility', 'Utility', []), }); - const utilityState = forceUnit.rules.getEquipmentToHit(utility); - expect((utilityState).modifier).toBe(0); - expect(utilityState).toEqual(jasmine.objectContaining({ modifiers: [] })); + const utilityModifiers = forceUnit.rules.getEquipmentToHitModifiers(utility); + expect(toHitModifierTotal(utilityModifiers)).toBe(0); + expect(utilityModifiers).toEqual([]); }); it('does not apply the spotting attack modifier with an active command console', () => { @@ -1118,9 +1320,9 @@ describe('MekRules', () => { }); forceUnit.turnState().spotting.set(true); - const noSpottingState = forceUnit.rules.getEquipmentToHit(directFireWeaponEntry(forceUnit)); - expect((noSpottingState).modifier).toBe(0); - expect(noSpottingState).toEqual(jasmine.objectContaining({ modifiers: [] })); + const noSpottingModifiers = forceUnit.rules.getEquipmentToHitModifiers(directFireWeaponEntry(forceUnit)); + expect(toHitModifierTotal(noSpottingModifiers)).toBe(0); + expect(noSpottingModifiers).toEqual([]); }); it('applies the spotting attack modifier without a command console', () => { @@ -1130,9 +1332,9 @@ describe('MekRules', () => { }); forceUnit.turnState().spotting.set(true); - const spottingState = forceUnit.rules.getEquipmentToHit(directFireWeaponEntry(forceUnit)); - expect((spottingState).modifier).toBe(1); - expect(spottingState).toEqual(jasmine.objectContaining({ modifiers: [{ label: 'Spotting', modifier: 1 }] })); + const spottingModifiers = forceUnit.rules.getEquipmentToHitModifiers(directFireWeaponEntry(forceUnit)); + expect(toHitModifierTotal(spottingModifiers)).toBe(1); + expect(spottingModifiers).toEqual([{ label: 'Spotting', modifier: 1 }]); }); it('applies skidding and spotting to ranged and physical equipment modifiers', () => { @@ -1140,22 +1342,18 @@ describe('MekRules', () => { forceUnit.setCondition('skidding', true); forceUnit.turnState().spotting.set(true); - const rangedState = forceUnit.rules.getEquipmentToHit(directFireWeaponEntry(forceUnit)); - expect((rangedState).modifier).toBe(2); - expect(rangedState).toEqual(jasmine.objectContaining({ - modifiers: [ - { label: 'Skidding', modifier: 1 }, - { label: 'Spotting', modifier: 1 }, - ], - })); - const physicalState = forceUnit.rules.getEquipmentToHit(punchEntry(forceUnit)); - expect((physicalState).modifier).toBe(2); - expect(physicalState).toEqual(jasmine.objectContaining({ - modifiers: [ - { label: 'Skidding', modifier: 1 }, - { label: 'Spotting', modifier: 1 }, - ], - })); + const rangedModifiers = forceUnit.rules.getEquipmentToHitModifiers(directFireWeaponEntry(forceUnit)); + expect(toHitModifierTotal(rangedModifiers)).toBe(2); + expect(rangedModifiers).toEqual([ + { label: 'Skidding', modifier: 1 }, + { label: 'Spotting', modifier: 1 }, + ]); + const physicalModifiers = forceUnit.rules.getEquipmentToHitModifiers(punchEntry(forceUnit)); + expect(toHitModifierTotal(physicalModifiers)).toBe(2); + expect(physicalModifiers).toEqual([ + { label: 'Skidding', modifier: 1 }, + { label: 'Spotting', modifier: 1 }, + ]); }); it('uses crew order instead of best skill for non-Tripod Mek target-number skills', () => { @@ -1368,7 +1566,7 @@ describe('MekRules', () => { expect(storedEntry.committedDestroyed()).toBeFalse(); expect(forceUnit.getCritSlots()[0].destroyed).toBeTruthy(); - expect((forceUnit.rules as MekRules).getEquipmentStatus(storedEntry)).toBe('destroyed'); + expect(forceUnit.getEquipmentStatus(storedEntry)).toBe('destroyed'); expect(forceUnit.getCondition('disconnected')).toBeTrue(); expect(forceUnit.getCondition('immobile')).toBeTrue(); @@ -1402,7 +1600,7 @@ describe('MekRules', () => { expect(forceUnit.getCritSlots()[0].destroyed).toBeFalsy(); expect(forceUnit.getCritSlots()[1].destroyed).toBeTruthy(); expect(storedEntry.committedDestroyed()).toBeFalse(); - expect(rules.getEquipmentStatus(storedEntry)).toBe('destroyed'); + expect(forceUnit.getEquipmentStatus(storedEntry)).toBe('destroyed'); }); it('requires two destroyed critical slots for Core2026 autocannons', () => { @@ -1422,12 +1620,12 @@ describe('MekRules', () => { forceUnit.applyHitToCritSlot(firstCrit); forceUnit.endPhase(); - expect(forceUnit.rules.getEquipmentStatus(storedEntry) === 'destroyed') + expect(forceUnit.getEquipmentStatus(storedEntry) === 'destroyed') .withContext(`${ammoType} after one destroyed critical slot`).toBeFalse(); forceUnit.applyHitToCritSlot(secondCrit); forceUnit.endPhase(); - expect(forceUnit.rules.getEquipmentStatus(storedEntry) === 'destroyed') + expect(forceUnit.getEquipmentStatus(storedEntry) === 'destroyed') .withContext(`${ammoType} after two destroyed critical slots`).toBeTrue(); } }); @@ -1442,7 +1640,7 @@ describe('MekRules', () => { forceUnit.applyHitToCritSlot(critSlot); forceUnit.endPhase(); - expect(forceUnit.rules.getEquipmentStatus(forceUnit.getInventory()[0])).toBe('destroyed'); + expect(forceUnit.getEquipmentStatus(forceUnit.getInventory()[0])).toBe('destroyed'); }); it('uses the one-slot threshold when a Core2026 autocannon signature does not match', () => { @@ -1462,7 +1660,7 @@ describe('MekRules', () => { forceUnit.applyHitToCritSlot(critSlot); forceUnit.endPhase(); - expect(forceUnit.rules.getEquipmentStatus(forceUnit.getInventory()[0])) + expect(forceUnit.getEquipmentStatus(forceUnit.getInventory()[0])) .withContext(testCase.description).toBe('destroyed'); } }); @@ -2286,7 +2484,7 @@ describe('MekRules', () => { })); expect(rules.PSRModifiers().modifiers.some(modifier => modifier.reason === 'Leg Actuator(s) Destroyed')).toBeFalse(); expect(rules.PSRModifiers().modifiers).toContain(jasmine.objectContaining({ pilotCheck: 2, reason: 'Gyro damaged' })); - expect((rules.getEquipmentToHit(armWeapon)).modifier).toBe(0); + expect(toHitModifierTotal(rules.getEquipmentToHitModifiers(armWeapon))).toBe(0); const twForceUnit = createForceUnitHarness({ internalLocations: ['LL', 'RL', 'LA', 'RA'], @@ -2308,7 +2506,7 @@ describe('MekRules', () => { pilotCheck: 1, loc: 'RL', reason: 'Leg Actuator(s) Destroyed', })); expect(twRules.PSRModifiers().modifiers).toContain(jasmine.objectContaining({ pilotCheck: 3, reason: 'Gyro damaged' })); - expect((twRules.getEquipmentToHit(twArmWeapon)).modifier).toBe(1); + expect(toHitModifierTotal(twRules.getEquipmentToHitModifiers(twArmWeapon))).toBe(1); }); it('treats adding flooded and blown-off Mek locations as pending until phase commit', () => { @@ -2355,20 +2553,19 @@ describe('MekRules', () => { forceUnit.setLocationCondition('LL', 'flooded', true); - expect(rules.getEquipmentStatus(entry)).toBe('available'); + expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); forceUnit.endPhase(); - expect(rules.getEquipmentStatus(entry)).toBe('disabled'); + expect(forceUnit.getEquipmentStatus(entry)).toBe('disabled'); forceUnit.setLocationCondition('LL', 'flooded', false); - expect(rules.getEquipmentStatus(entry)).toBe('available'); + expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); }); it('marks blown-off location inventory as damaged and disabled without destroying it', () => { const forceUnit = createForceUnitHarness({ internalLocations: ['LL'] }); - const rules = forceUnit.rules as MekRules; const critSlot = { id: 'test-weapon', name: 'Test Weapon', loc: 'LL', slot: 0 } as CriticalSlot; const entry = new MountedEquipment({ owner: forceUnit, id: 'test-entry', name: 'Test Entry', locations: new Set(['LL']), critSlots: [critSlot] }); @@ -2377,12 +2574,10 @@ describe('MekRules', () => { const storedEntry = forceUnit.getInventory().find(item => item.id === entry.id)!; forceUnit.setLocationCondition('LL', 'blown-off', true); forceUnit.endPhase(); - rules.getEquipmentToHits(); - expect(forceUnit.isInternalLocCommittedPhysicallyDestroyed('LL')).toBeTrue(); expect(forceUnit.getCritSlots().every(slot => !slot.destroying && !slot.destroyed)).toBeTrue(); expect(storedEntry.committedDestroyed()).toBeFalse(); - expect(rules.getEquipmentStatus(storedEntry)).toBe('destroyed'); + expect(forceUnit.getEquipmentStatus(storedEntry)).toBe('destroyed'); }); it('marks inventory in structurally destroyed locations as damaged and disabled', () => { @@ -2396,12 +2591,14 @@ describe('MekRules', () => { const storedEntry = forceUnit.getInventory().find(item => item.id === entry.id)!; forceUnit.addInternalHits('LL', forceUnit.getInternalPoints('LL')); forceUnit.endPhase(); - const equipmentToHits = rules.getEquipmentToHits(); + const firstModifiers = rules.getEquipmentToHitModifiers(storedEntry); + const secondModifiers = rules.getEquipmentToHitModifiers(storedEntry); expect(forceUnit.isInternalLocCommittedStructurallyDestroyed('LL')).toBeTrue(); expect(storedEntry.committedDestroyed()).toBeFalse(); - expect(equipmentToHits.has(storedEntry)).toBeTrue(); - expect(rules.getEquipmentStatus(storedEntry)).toBe('destroyed'); + expect(firstModifiers).toEqual(secondModifiers); + expect(firstModifiers).not.toBe(secondModifiers); + expect(forceUnit.getEquipmentStatus(storedEntry)).toBe('destroyed'); }); it('marks linked locations blown off by parent structural destruction as damaged and disabled', () => { @@ -2418,17 +2615,22 @@ describe('MekRules', () => { const storedLinkedEntry = forceUnit.getInventory().find(item => item.id === linkedEntry.id)!; forceUnit.addInternalHits('RT', forceUnit.getInternalPoints('RT')); forceUnit.endPhase(); - const equipmentToHits = rules.getEquipmentToHits(); + const parentModifiers = rules.getEquipmentToHitModifiers(storedParentEntry); + const linkedModifiers = rules.getEquipmentToHitModifiers(storedLinkedEntry); expect(forceUnit.isInternalLocCommittedStructurallyDestroyed('RT')).toBeTrue(); expect(forceUnit.isInternalLocCommittedStructurallyDestroyed('RA')).toBeFalse(); expect(forceUnit.isInternalLocCommittedPhysicallyDestroyed('RA')).toBeTrue(); expect(storedParentEntry.committedDestroyed()).toBeFalse(); expect(storedLinkedEntry.committedDestroyed()).toBeFalse(); - expect(equipmentToHits.has(storedParentEntry)).toBeTrue(); - expect(equipmentToHits.has(storedLinkedEntry)).toBeTrue(); - expect(rules.getEquipmentStatus(storedParentEntry)).toBe('destroyed'); - expect(rules.getEquipmentStatus(storedLinkedEntry)).toBe('destroyed'); + const freshParentModifiers = rules.getEquipmentToHitModifiers(storedParentEntry); + const freshLinkedModifiers = rules.getEquipmentToHitModifiers(storedLinkedEntry); + expect(parentModifiers).toEqual(freshParentModifiers); + expect(parentModifiers).not.toBe(freshParentModifiers); + expect(linkedModifiers).toEqual(freshLinkedModifiers); + expect(linkedModifiers).not.toBe(freshLinkedModifiers); + expect(forceUnit.getEquipmentStatus(storedParentEntry)).toBe('destroyed'); + expect(forceUnit.getEquipmentStatus(storedLinkedEntry)).toBe('destroyed'); }); it('disables linked-location inventory from flooded torsos without marking it damaged', () => { @@ -2441,7 +2643,7 @@ describe('MekRules', () => { expect(forceUnit.isInternalLocCommittedDestroyed('LA')).toBeTrue(); expect(forceUnit.isInternalLocCommittedPhysicallyDestroyed('LA')).toBeFalse(); - expect(rules.getEquipmentStatus(entry)).toBe('disabled'); + expect(forceUnit.getEquipmentStatus(entry)).toBe('disabled'); }); it('counts flooded critical slots as functionally destroyed without committing crit destruction', () => { diff --git a/src/app/models/rules/mek-rules.ts b/src/app/models/rules/mek-rules.ts index 54e6d867c..25e882964 100644 --- a/src/app/models/rules/mek-rules.ts +++ b/src/app/models/rules/mek-rules.ts @@ -3,11 +3,12 @@ // Author: Drake import { computed } from '@angular/core'; -import type { CBTForceUnit } from '../cbt-force-unit.model'; +import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; import type { CrewMember, SkillType } from '../crew-member.model'; import type { MountedEquipment } from '../mounted-equipment.model'; import type { CriticalSlot, RuleCheckOutcome } from '../force-serialization'; -import { CrewStateControlDefinition, CrewStateDefinition, crewStateDefinitions, sortPSRModifiers, UnitConditionControl, unitConditionControls, UnitTypeRulesBase, type ChargeDamage, type LocationConditionControl, type PSRCheck, type MountedEquipmentStatus, type MountedEquipmentToHit, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitRuleModifier } from './unit-type-rules'; +import { CrewStateControlDefinition, CrewStateDefinition, crewStateDefinitions, sortPSRModifiers, UnitConditionControl, unitConditionControls, UnitTypeRulesBase, type ChargeDamage, type LocationConditionControl, type PSRCheck, type UnitHeatSource, type UnitModifierBreakdownEntry, type UnitRuleModifier } from './unit-type-rules'; +import type { EquipmentStatus, EquipmentStatusFacts } from '../equipment-status.model'; import type { TurnState } from '../turn-state.model'; import { type HeatScaleEntry, HeatManagement, getHeatEffects } from './heat-management'; import type { MotiveModes } from '../motiveModes.model'; @@ -24,7 +25,6 @@ import { export { LEG_LOCATIONS } from '../entity/types'; import type { InventoryControlDisplayData } from '../../utils/inventory-control.util'; -import { WeaponEquipment } from '../equipment.model'; import type { ToHitModifierBreakdownEntry } from './game-rules'; import { uuidv7 } from '../../utils/uuid.util'; @@ -262,7 +262,7 @@ export class MekRules extends UnitTypeRulesBase { } private isCritUnavailable(slot: CriticalSlot): boolean { - return this.unit.isEquipmentUnavailable(slot); + return !this.unit.isEquipmentOperational(slot); } private isCritStructurallyDestroyed(slot: CriticalSlot): boolean { @@ -884,8 +884,6 @@ export class MekRules extends UnitTypeRulesBase { const cockpitLoc = critSlots.find(slot => this.isNamedCrit(slot, "Cockpit"))?.loc ?? 'HD'; const destroyedSensorsCountInHD = critSlots.filter(slot => slot.loc === 'HD' && this.isNamedCrit(slot, 'Sensor') && this.isCritUnavailable(slot)).length; const destroyedSensorsCount = critSlots.filter(slot => this.isNamedCrit(slot, 'Sensor') && this.isCritUnavailable(slot)).length; - const hasTargetingComputer = critSlots.some(slot => this.isNamedCrit(slot, 'Targeting Computer')); - const destroyedTargetingComputers = critSlots.filter(slot => this.isNamedCrit(slot, 'Targeting Computer') && this.isCritUnavailable(slot)).length; const internalLocations = new Set(this.unit.locations?.internal?.keys() || []); @@ -1005,8 +1003,6 @@ export class MekRules extends UnitTypeRulesBase { cockpitLoc, destroyedSensorsCountInHD, destroyedSensorsCount, - hasTargetingComputer, - destroyedTargetingComputers, destroyedLegAES, hasLegAES, hasFunctionalLegAES, @@ -1757,10 +1753,10 @@ export class MekRules extends UnitTypeRulesBase { location?: string, ignoreMyomer = false, ): { damage: number; text: string; weakened: boolean } { - const effect = this.unit.getInventoryControlRules().applyPhysicalDamageEffects?.(entry, { + const effect = this.unit.getEffectivePhysicalDamageEffect(entry, { baseDamage, ignoreMyomer, - }) ?? { baseDamage, ignoreMyomer }; + }); const { damage, maxDamage } = this.computeMeleeDamage( effect.baseDamage, attackType, @@ -1823,74 +1819,46 @@ export class MekRules extends UnitTypeRulesBase { }; }); - // ── Per-Entry Inventory State ───────────────────────────────────────────── - - /** Compute to-hit state for all inventory entries in one reactive pass. */ - private readonly equipmentToHits = computed>(() => { - const entries = this.unit.getInventory(); - const result = new Map(); - for (const entry of entries) { - result.set(entry, this.getEquipmentToHit(entry)); - } - return result; - }); - - override getEquipmentToHits(): Map { - return this.equipmentToHits(); - } - - private isEntryDestroyedByCriticalDamage(entry: MountedEquipment): boolean { - const destroyedCritSlots = this.entryCriticalSlots(entry).filter(slot => this.isCritStructurallyDestroyed(slot)).length; - return destroyedCritSlots >= this.criticalDamageDestructionThreshold(entry); - } - - protected criticalDamageDestructionThreshold(entry: MountedEquipment): number { - const equipment = entry.equipment; - const isAutocannon = equipment instanceof WeaponEquipment && equipment.hasFlag('F_AC'); - return isAutocannon ? 2 : 1; - } - - /** Resolve operational status without modifier-producing equipment interactions. */ - override getEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus { - const physicallyDestroyed = this.entryInPhysicallyDestroyedLocation(entry); - const functionallyDestroyed = this.entryInFunctionallyDestroyedLocation(entry); - if (entry.committedDestroyed() || physicallyDestroyed || this.isEntryDestroyedByCriticalDamage(entry)) { - return 'destroyed'; - } - let disabled = functionallyDestroyed || this.isEntryStateDisabled(entry); + override canPerformEquipmentAction(entry: MountedEquipment, action: EquipmentAction): boolean { + if (action === 'fire') return this.fireControl()?.canFire ?? true; + if (action !== 'physical-attack') return true; const physical = this.physicalCombat(); - const fire = this.fireControl(); - if (!physical || !fire) return disabled ? 'disabled' : 'available'; - + if (!physical) return true; if (entry.isIntrinsicPhysicalAttack()) { switch (entry.name.toLowerCase()) { - case 'punch': - const loc = Array.from(entry.locations!)[0] as ArmLocation; - if (loc in physical.canPunch && !physical.canPunch[loc]) disabled = true; - break; + case 'punch': { + const loc = Array.from(entry.locations ?? [])[0] as ArmLocation | undefined; + return loc === undefined || !(loc in physical.canPunch) || physical.canPunch[loc] === true; + } case 'club': - if (!physical.canClub) disabled = true; - break; + return physical.canClub === true; case 'push': - if (!physical.canPush) disabled = true; - break; + return physical.canPush; case 'kick [talons]': case 'kick': - if (!physical.canKick) disabled = true; - break; + return physical.canKick; + default: + return true; } - } else if (entry.isPhysicalWeapon()) { - entry.locations?.forEach(loc => { - if ((loc in physical.canPhysWeapon) && !physical.canPhysWeapon[loc as ArmLocation]) disabled = true; - }); - } else if (!fire.canFire) { - disabled = true; } - return disabled ? 'disabled' : 'available'; + if (!entry.isPhysicalWeapon()) return true; + return Array.from(entry.locations ?? []).every(location => + !(location in physical.canPhysWeapon) || physical.canPhysWeapon[location as ArmLocation] === true + ); + } + + override getMountedCriticalStatusContribution(facts: EquipmentStatusFacts): EquipmentStatus { + const destroyedCriticalCount = facts.criticals.filter(critical => critical.status === 'destroyed').length; + const threshold = this.mountedCriticalDamageDestructionThreshold(facts); + return destroyedCriticalCount >= threshold ? 'destroyed' : 'available'; + } + + protected mountedCriticalDamageDestructionThreshold(facts: EquipmentStatusFacts): number { + return facts.equipmentFlags.has('F_AC') ? 2 : 1; } - protected override getEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[] { + override getEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[] { const hitModifierBreakdown: ToHitModifierBreakdownEntry[] = []; const physical = this.physicalCombat(); const fire = this.fireControl(); @@ -1985,13 +1953,7 @@ export class MekRules extends UnitTypeRulesBase { } }); const tarcompWeapon = entry.parent ?? entry; - if (systemsStatus.hasTargetingComputer && this.isTargetingComputerEligible(tarcompWeapon)) { - if (systemsStatus.destroyedTargetingComputers === 0) { - hitModifierBreakdown.push({ label: 'Targeting Computer', modifier: -1 }); - } else { - hitModifierBreakdown.push({ label: 'Targeting Computer Destroyed', modifier: 0, weakened: true }); - } - } + hitModifierBreakdown.push(...this.getMountedTargetingComputerModifiers(tarcompWeapon)); } return [...hitModifierBreakdown, ...this.getUnitEquipmentToHitModifiers(entry)]; } diff --git a/src/app/models/rules/protomek-rules.spec.ts b/src/app/models/rules/protomek-rules.spec.ts index 5ef48e111..174c3acfe 100644 --- a/src/app/models/rules/protomek-rules.spec.ts +++ b/src/app/models/rules/protomek-rules.spec.ts @@ -20,7 +20,7 @@ function createRulesHarness(crewStates: CrewMemberState[] = ['healthy'], crewHit isCrippled: () => (crewHits[index] ?? 0) >= CRIPPLED_CREW_HIT_THRESHOLD, })), getUnit: () => baseUnit, - isEquipmentUnavailable: () => true, + isEquipmentOperational: () => false, isLoaded: () => true, locations: { internal: new Map() }, destroyed: false, diff --git a/src/app/models/rules/tw-rules.spec.ts b/src/app/models/rules/tw-rules.spec.ts index 808cc34cd..a1676d5b9 100644 --- a/src/app/models/rules/tw-rules.spec.ts +++ b/src/app/models/rules/tw-rules.spec.ts @@ -26,11 +26,17 @@ let injector: Injector; let optionsService: OptionsService; function legActuatorCrit(id: string, name: string, loc: string, destroyed = true): CriticalSlot { + const slotByActuator = new Map([ + ['hip', 0], + ['upper-leg', 1], + ['lower-leg', 2], + ['foot', 3], + ]); return { id, name, loc, - slot: 0, + slot: slotByActuator.get(id) ?? 0, destroyed: destroyed ? 1 : undefined, }; } diff --git a/src/app/models/rules/tw-rules.ts b/src/app/models/rules/tw-rules.ts index 7abb36506..a87378723 100644 --- a/src/app/models/rules/tw-rules.ts +++ b/src/app/models/rules/tw-rules.ts @@ -65,7 +65,7 @@ export class TWMekRules extends MekRules { const modifiers: PSRCheck[] = []; const destroyedHips = critSlots.filter(slot => slot.loc && LEG_LOCATIONS.has(slot.loc) - && this.unit.isEquipmentUnavailable(slot) + && !this.unit.isEquipmentOperational(slot) && !ignoreLeg.has(slot.loc) && this.isNamedCrit(slot, 'Hip')); for (const hip of destroyedHips) { @@ -75,7 +75,7 @@ export class TWMekRules extends MekRules { } const destroyedActuators = critSlots.filter(slot => slot.loc && LEG_LOCATIONS.has(slot.loc) - && this.unit.isEquipmentUnavailable(slot) + && !this.unit.isEquipmentOperational(slot) && !ignoreLeg.has(slot.loc) && (this.isNamedCrit(slot, 'Leg') || this.isNamedCrit(slot, 'Foot'))); const destroyedActuatorCounts = new Map(); @@ -163,7 +163,7 @@ export class TWMekRules extends MekRules { protected override gyroHitPSRCheck(gyroHits: number): PSRCheck | null { if (this.hasHeavyDutyGyro()) { const previouslyDestroyedGyroCount = this.unit.getCritSlots() - .filter(slot => this.unit.isEquipmentUnavailable(slot) && slot.name?.includes('Gyro')).length; + .filter(slot => !this.unit.isEquipmentOperational(slot) && slot.name?.includes('Gyro')).length; if (previouslyDestroyedGyroCount + gyroHits === 1) { return { pilotCheck: 1, reason: 'Gyro hit' }; } @@ -199,7 +199,7 @@ export class TWMekRules extends MekRules { protected override gyroPSRModifierHitCount(): number { return this.unit.getCritSlots() - .filter(slot => this.unit.isEquipmentUnavailable(slot) && slot.name?.includes('Gyro')).length; + .filter(slot => !this.unit.isEquipmentOperational(slot) && slot.name?.includes('Gyro')).length; } protected override preExistingGyroPSRModifier(destroyedGyroCount: number): PSRCheck | null { @@ -210,7 +210,7 @@ export class TWMekRules extends MekRules { return { pilotCheck: this.gyroHitPSRModifier, reason: 'Gyro damaged' }; } - protected override criticalDamageDestructionThreshold(): number { + protected override mountedCriticalDamageDestructionThreshold(): number { return 1; } diff --git a/src/app/models/rules/unit-type-rules.ts b/src/app/models/rules/unit-type-rules.ts index 49d80aae1..a4f87d8e3 100644 --- a/src/app/models/rules/unit-type-rules.ts +++ b/src/app/models/rules/unit-type-rules.ts @@ -6,7 +6,6 @@ import { computed, signal, type Signal } from '@angular/core'; import { MountedWeapon, type MountedEquipment } from '../mounted-equipment.model'; import { ATTACK_MOVEMENT_MODIFIER_BREAKDOWN_PRIORITY, type ToHitModifierBreakdownEntry } from './game-rules'; import { WeaponEquipment } from '../equipment.model'; -import type { WeaponType } from '../weapon-types.model'; import type { CriticalSlot, RuleCheckOutcome, SerializedC3NetworkGroup } from '../force-serialization'; import { getMotiveModeLabel, type MotiveModes } from '../motiveModes.model'; import type { TurnState } from '../turn-state.model'; @@ -19,10 +18,16 @@ import { TN_SKIDDING_ATTACKER, TN_SKIDDING_MODIFIER, } from '../target-number-calculator.model'; -import type { CBTForceUnit } from '../cbt-force-unit.model'; +import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; import type { HeatDissipationState } from './heat-management'; import type { InventoryControlDisplayData } from '../../utils/inventory-control.util'; import { C3TaxCalculator } from '../c3-network.model'; +import type { + CriticalSlotStatusFacts, + EquipmentStatus, + EquipmentStatusFacts, + UnitSystemStatusFacts, +} from '../equipment-status.model'; export interface PSRCheck { id?: string; @@ -66,13 +71,6 @@ export interface UnitHeatSource { replacedByFiringEntryId?: string; } -export type MountedEquipmentStatus = 'available' | 'disabled' | 'destroyed'; - -export interface MountedEquipmentToHit { - readonly modifier: number; - readonly modifiers: readonly ToHitModifierBreakdownEntry[]; -} - export interface ChargeDamage { damage: number | null; maxDamage: number | null; @@ -290,14 +288,26 @@ export interface UnitTypeRules { /** Rule-derived condition keys exposed through ForceUnit.getCondition/getConditions. */ computedConditions(): readonly string[]; - /** Resolve operational status without invoking equipment interaction handlers. */ - getEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus; + /** Unit-type-specific status contribution from status-only facts. */ + getEquipmentStatusContribution(facts: EquipmentStatusFacts): EquipmentStatus; + + /** Aggregate current critical facts into mount-level status. */ + getMountedCriticalStatusContribution(facts: EquipmentStatusFacts): EquipmentStatus; + + /** Location-scoped unit-type-specific status contribution. */ + getEquipmentStatusContributionAtLocation(facts: EquipmentStatusFacts, location: string): EquipmentStatus; + + /** Unit-type-specific critical-slot status contribution. */ + getCriticalSlotStatusContribution(facts: CriticalSlotStatusFacts): EquipmentStatus; - /** Resolve to-hit modifiers for all inventory entries. */ - getEquipmentToHits(): Map; + /** Status-only system facts exposed to canonical composition. */ + getUnitSystemStatusFacts(): UnitSystemStatusFacts; + + /** Unit-type-specific permission for an otherwise operational equipment action. */ + canPerformEquipmentAction(entry: MountedEquipment, action: EquipmentAction): boolean; /** Resolve rule-derived to-hit modifiers for one inventory entry. */ - getEquipmentToHit(entry: MountedEquipment): MountedEquipmentToHit; + getEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[]; /** Required control-roll checks for the current phase. */ getPSRChecks(turnState: TurnState): PSRCheck[]; @@ -480,30 +490,31 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { return ['abandoned', 'immobile', 'crippled', 'disconnected', 'spotting']; } - getEquipmentToHits(): Map { - const result = new Map(); - for (const entry of this.unit.getInventory()) { - result.set(entry, this.getEquipmentToHit(entry)); - } - return result; + getEquipmentStatusContribution(facts: EquipmentStatusFacts): EquipmentStatus { + return 'available'; } - getEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus { - if (entry.committedDestroyed() || this.entryCriticalSlots(entry).some(slot => !!slot.destroyed)) { - return 'destroyed'; - } - return this.isEntryStateDisabled(entry) ? 'disabled' : 'available'; + getMountedCriticalStatusContribution(facts: EquipmentStatusFacts): EquipmentStatus { + return facts.criticals.some(critical => critical.status === 'destroyed') ? 'destroyed' : 'available'; } - getEquipmentToHit(entry: MountedEquipment): MountedEquipmentToHit { - const modifiers = this.getEquipmentToHitModifiers(entry); - return { - modifier: modifiers.reduce((total, modifier) => total + modifier.modifier, 0), - modifiers, - }; + getEquipmentStatusContributionAtLocation(facts: EquipmentStatusFacts, _location: string): EquipmentStatus { + return this.getEquipmentStatusContribution(facts); } - protected getEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[] { + getCriticalSlotStatusContribution(_facts: CriticalSlotStatusFacts): EquipmentStatus { + return 'available'; + } + + getUnitSystemStatusFacts(): UnitSystemStatusFacts { + return { engineHit: false }; + } + + canPerformEquipmentAction(_entry: MountedEquipment, _action: EquipmentAction): boolean { + return true; + } + + getEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[] { return [ ...this.getMountedTargetingComputerModifiers(entry), ...this.getUnitEquipmentToHitModifiers(entry), @@ -520,33 +531,30 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { } protected getMountedTargetingComputerModifiers(entry: MountedEquipment): ToHitModifierBreakdownEntry[] { + const targetingComputer = this.getMountedTargetingComputer(); + if (!targetingComputer) return []; if (!this.isTargetingComputerEligible(entry)) return []; - const targetingComputers = this.unit.getInventory() - .filter(candidate => candidate.equipment?.flags.has('F_TARGETING_COMPUTER')); - if (targetingComputers.length === 0) return []; - - const functionalTargetingComputer = targetingComputers.find(candidate => - !candidate.committedDestroyed() - && !this.isEntryStateDisabled(candidate) - && !this.entryCriticalSlots(candidate).some(slot => !!slot.destroyed) - ); - const targetingComputer = functionalTargetingComputer ?? targetingComputers[0]; - const label = targetingComputer.equipment?.shortName ?? targetingComputer.name; - return functionalTargetingComputer + const label = targetingComputer.equipment?.name ?? targetingComputer.name; + const status = this.unit.getEquipmentStatus(targetingComputer); + return status === 'available' ? [{ label, modifier: -1 }] - : [{ label: `${label} Destroyed`, modifier: 0, weakened: true }]; + : [{ + label: `${label} ${status === 'destroyed' ? 'Destroyed' : 'Disabled'}`, + modifier: 0, + weakened: true, + }]; } - canMakeTargetingComputerAimedShot(entry: MountedEquipment, targetIsMobile: boolean): boolean { - const weapon = entry.parent instanceof MountedWeapon ? entry.parent : entry; - return weapon instanceof MountedWeapon - && !(targetIsMobile && weapon.equipment.hasFlag('F_PULSE')); + private getMountedTargetingComputer(): MountedEquipment | undefined { + // There can be at most only 1 targeting computer so, we pick the first! + return this.unit.getInventory() + .find(candidate => candidate.equipment?.flags.has('F_TARGETING_COMPUTER')); } protected isTargetingComputerEligible(entry: MountedEquipment): boolean { if (!(entry instanceof MountedWeapon)) return false; - const effectiveTypes = this.getEffectiveWeaponTypes(entry); + const effectiveTypes = this.unit.getEffectiveWeaponTypes(entry); return entry.equipment.hasFlag('F_DIRECT_FIRE') === true && !entry.equipment.hasAnyFlag(['F_TASER', 'F_FLAMER', 'F_MG', 'F_MGA']) && (effectiveTypes.has('DB') || effectiveTypes.has('DE') || effectiveTypes.has('P')) @@ -554,25 +562,12 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { && (!effectiveTypes.has('C') || entry.equipment.hasFlag('F_HAG')); } - private getEffectiveWeaponTypes(entry: MountedWeapon): ReadonlySet { - const selectedAmmo = this.unit.getInventoryControlSelectedAmmo?.(entry) ?? null; - const baseTypes = new Set(entry.getWeaponTypes(selectedAmmo)); - return this.unit.getInventoryControlRules().applyWeaponTypes?.(entry, baseTypes) ?? baseTypes; - } - - protected isEntryStateDisabled(entry: MountedEquipment): boolean { - return entry.states.get(ENTRY_DISABLED_STATE_KEY) === ENTRY_DISABLED_STATE_VALUE; - } - protected entryCriticalSlots(entry: MountedEquipment): CriticalSlot[] { - return entry.critSlots?.map(slot => this.currentCriticalSlot(slot)) ?? []; + return entry.critSlots?.flatMap(slot => this.currentCriticalSlot(slot) ?? []) ?? []; } - protected currentCriticalSlot(slot: CriticalSlot): CriticalSlot { - return this.unit.getCritSlots().find(candidate => { - if (slot.loc && slot.slot !== undefined) return candidate.loc === slot.loc && candidate.slot === slot.slot; - return !!slot.id && candidate.id === slot.id; - }) ?? slot; + protected currentCriticalSlot(slot: CriticalSlot): CriticalSlot | null { + return this.unit.findCurrentCriticalSlot(slot); } crewStateDefinition(state: CrewMemberState): CrewStateDefinition | undefined { @@ -605,7 +600,7 @@ export abstract class UnitTypeRulesBase implements UnitTypeRules { protected isDroneOperatingSystemUnavailable(): boolean { const droneOperatingSystem = this.droneOperatingSystem(); - return droneOperatingSystem !== undefined && this.unit.isEquipmentUnavailable(droneOperatingSystem); + return droneOperatingSystem !== undefined && !this.unit.isEquipmentOperational(droneOperatingSystem); } getPSRChecks(_turnState: TurnState): PSRCheck[] { diff --git a/src/app/models/rules/vehicle-rules.spec.ts b/src/app/models/rules/vehicle-rules.spec.ts index 1b190099f..a694996f9 100644 --- a/src/app/models/rules/vehicle-rules.spec.ts +++ b/src/app/models/rules/vehicle-rules.spec.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import type { CBTForceUnit } from '../cbt-force-unit.model'; +import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; import type { CrewMemberState } from '../crew-member.model'; import { MountedEquipment, MountedWeapon } from '../mounted-equipment.model'; import { type CriticalSlot } from '../force-serialization'; @@ -16,6 +16,9 @@ import { MascHandler, MASC_ACTIVE_STATE_KEY } from '../../equipment-handlers/mas import { TWVehicleRules } from './tw-rules'; import { CORE_2026_GAME_RULES, TW_GAME_RULES } from './game-rules'; import { EquipmentFlag } from '../equipment-flags.type'; +import { combineEquipmentStatuses, type EquipmentStatus, type EquipmentStatusFacts } from '../equipment-status.model'; +import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from './unit-type-rules'; +import { createHandlerQueryContext } from '../../services/equipment-interaction-registry.service'; const mascHandler = new MascHandler(); @@ -99,14 +102,51 @@ function createRulesHarness(options: { getSkill: (skill: 'gunnery' | 'piloting') => skill === 'gunnery' ? options.gunnery ?? 4 : 5, })); let rules: VehicleRules; + const findCurrentCriticalSlot = (slot: CriticalSlot): CriticalSlot | null => (options.crits ?? []).find(candidate => + slot.loc && slot.slot !== undefined + ? candidate.loc === slot.loc && candidate.slot === slot.slot + : !!slot.id && candidate.id === slot.id + ) ?? null; + const getEquipmentStatus = (source: MountedEquipment | CriticalSlot): EquipmentStatus => { + if (!(source instanceof MountedEquipment)) return source.destroyed ? 'destroyed' : 'available'; + + const criticals = source.critSlots?.flatMap(slot => findCurrentCriticalSlot(slot) ?? []) ?? []; + const mountState: EquipmentStatus = source.committedDestroyed() + ? 'destroyed' + : source.states.get(ENTRY_DISABLED_STATE_KEY) === ENTRY_DISABLED_STATE_VALUE + ? 'disabled' + : 'available'; + const facts: EquipmentStatusFacts = { + equipment: source.equipment ?? null, + equipmentId: source.equipment?.id ?? source.id, + equipmentFlags: source.equipment?.flags ?? new Set(), + mountState, + criticals: criticals.map(slot => ({ + id: slot.id ?? `${slot.loc ?? ''}:${slot.slot ?? ''}`, + location: slot.loc ?? null, + slot: slot.slot ?? null, + status: slot.destroyed ? 'destroyed' : 'available', + committedHits: slot.hits ?? (slot.destroyed ? 1 : 0), + armored: slot.armored === true, + })), + locationStates: new Map(), + unitSystemFacts: rules.getUnitSystemStatusFacts(), + }; + return combineEquipmentStatuses([ + facts.mountState, + rules.getMountedCriticalStatusContribution(facts), + rules.getEquipmentStatusContribution(facts), + ]); + }; const unit = { gameRules: options.rulesId === 'tw' ? TW_GAME_RULES : CORE_2026_GAME_RULES, getCritSlots: () => options.crits ?? [], + findCurrentCriticalSlot, getInventory: () => options.inventory ?? [], getEquipmentRegistry: () => new EquipmentRegistry(Object.fromEntries((options.inventory ?? []) .flatMap(entry => entry.equipment ? [[entry.equipment.internalName, entry.equipment]] : []))), getInventoryControlSelectedAmmo: () => options.selectedAmmo ?? null, - getInventoryControlRules: () => ({}), + getEffectiveWeaponTypes: (entry: MountedWeapon) => new Set(entry.getWeaponTypes(options.selectedAmmo ?? null)), getUnit: () => baseUnit, getCondition: (state: string) => { if (state === 'shutdown') return options.shutdown ?? false; @@ -115,9 +155,16 @@ function createRulesHarness(options: { }, getCrewMembers: () => crewMembers, getCrewMember: (id: number) => crewMembers[id], - isEquipmentUnavailable: (source: MountedEquipment | CriticalSlot) => source instanceof MountedEquipment ? source.committedDestroyed() : !!source.destroyed, + getEquipmentStatus, + isEquipmentOperational: (source: MountedEquipment | CriticalSlot) => unit.getEquipmentStatus(source) === 'available', + canPerformEquipmentAction: (entry: MountedEquipment, action: EquipmentAction) => + unit.isEquipmentOperational(entry) && rules.canPerformEquipmentAction(entry, action), getRunMovementMultiplierBonus: (turnState: TurnState) => (options.inventory ?? []) - .reduce((total, entry) => total + mascHandler.getRunMovementMultiplierBonus(entry, turnState), 0), + .reduce((total, entry) => total + mascHandler.getRunMovementMultiplierBonus( + entry, + turnState, + createHandlerQueryContext(unit.getEquipmentRegistry()), + ), 0), pilotingSkill: () => 5, gunnerySkill: () => options.gunnery ?? 4, turnState: () => ({ @@ -131,7 +178,9 @@ function createRulesHarness(options: { setDestroyed: jasmine.createSpy('setDestroyed'), } as unknown as CBTForceUnit; - options.inventory?.forEach(entry => entry.owner = unit); + options.inventory?.forEach(inventoryEntry => { + (inventoryEntry as { owner: CBTForceUnit }).owner = unit; + }); rules = options.rulesId === 'tw' ? new TWVehicleRules(unit) : new VehicleRules(unit); return rules; } @@ -156,7 +205,8 @@ describe('VehicleRules', () => { const targetingComputer = entry({ equipment: equipment('TargetingComputer', ['F_TARGETING_COMPUTER']) }); const activeRules = createRulesHarness({ inventory: [directFire, targetingComputer] }); - expect(activeRules.getEquipmentToHit(directFire).modifier).toBe(-1); + const activeModifiers = activeRules.getEquipmentToHitModifiers(directFire); + expect(activeModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(-1); const destroyedDirectFire = new MountedWeapon({ owner: undefined as unknown as CBTForceUnit, @@ -170,8 +220,9 @@ describe('VehicleRules', () => { }); const destroyedRules = createRulesHarness({ inventory: [destroyedDirectFire, destroyedTargetingComputer] }); - expect(destroyedRules.getEquipmentToHit(destroyedDirectFire).modifier).toBe(0); - expect(destroyedRules.getEquipmentToHit(destroyedDirectFire).modifiers) + const destroyedModifiers = destroyedRules.getEquipmentToHitModifiers(destroyedDirectFire); + expect(destroyedModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(0); + expect(destroyedModifiers) .toEqual([{ label: 'DestroyedTargetingComputer Destroyed', modifier: 0, weakened: true }]); }); @@ -202,7 +253,8 @@ describe('VehicleRules', () => { selectedAmmo: flechetteAmmo }); - expect(rules.getEquipmentToHit(mountedAutocannon).modifier).toBe(0); + const modifiers = rules.getEquipmentToHitModifiers(mountedAutocannon); + expect(modifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(0); }); it('excludes cluster and flak weapons from targeting computers except non-flak HAGs', () => { @@ -239,30 +291,12 @@ describe('VehicleRules', () => { }); const rules = createRulesHarness({ inventory: [clusterWeapon, flakWeapon, hag, targetingComputer] }); - expect(rules.getEquipmentToHit(clusterWeapon).modifier).toBe(0); - expect(rules.getEquipmentToHit(flakWeapon).modifier).toBe(0); - expect(rules.getEquipmentToHit(hag).modifier).toBe(-1); - }); - - it('does not allow pulse weapons to make aimed shots against mobile targets', () => { - const pulseWeapon = new MountedWeapon({ - owner: undefined as unknown as CBTForceUnit, - id: 'PulseWeapon', - name: 'PulseWeapon', - equipment: weapon('PulseWeapon', ['F_DIRECT_FIRE', 'F_ENERGY', 'F_PULSE']), - }); - const standardWeapon = new MountedWeapon({ - owner: undefined as unknown as CBTForceUnit, - id: 'StandardWeapon', - name: 'StandardWeapon', - equipment: weapon('StandardWeapon', ['F_DIRECT_FIRE', 'F_ENERGY']), - }); - const rules = createRulesHarness(); - - expect(rules.canMakeTargetingComputerAimedShot(pulseWeapon, true)).toBeFalse(); - expect(rules.canMakeTargetingComputerAimedShot(pulseWeapon, false)).toBeTrue(); - expect(rules.canMakeTargetingComputerAimedShot(standardWeapon, true)).toBeTrue(); - expect(rules.canMakeTargetingComputerAimedShot(entry(), false)).toBeFalse(); + const clusterModifiers = rules.getEquipmentToHitModifiers(clusterWeapon); + const flakModifiers = rules.getEquipmentToHitModifiers(flakWeapon); + const hagModifiers = rules.getEquipmentToHitModifiers(hag); + expect(clusterModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(0); + expect(flakModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(0); + expect(hagModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(-1); }); it('applies ordered motive movement damage by timestamp', () => { @@ -317,16 +351,34 @@ describe('VehicleRules', () => { expect(rules.getMaxDistanceForMoveMode('run')).toBe(16); }); + it('does not let a canonically disabled Supercharger provide passive movement', () => { + const disabledSupercharger = entry({ equipment: equipment('Supercharger', ['F_MASC', 'S_SUPERCHARGER']) }); + disabledSupercharger.setState(ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE); + const rules = createRulesHarness({ + inventory: [disabledSupercharger], + walk: 8, + }); + + expect(disabledSupercharger.owner.getEquipmentStatus(disabledSupercharger)).toBe('disabled'); + expect(rules.movementState()).toEqual(jasmine.objectContaining({ + run: 12, + maxRun: 12, + moveImpaired: false, + })); + }); + it('ignores destroyed vehicle boost equipment when calculating max run MP', () => { + const destroyedSuperchargerCrit = crit('Supercharger', 10); const destroyedSupercharger = entry({ equipment: equipment('Supercharger', ['F_MASC', 'S_SUPERCHARGER']), - critSlots: [crit('Supercharger', 10)], + critSlots: [destroyedSuperchargerCrit], }); const destroyedJetBooster = entry({ equipment: equipment('ISVTOLJetBooster', ['F_MASC', 'F_JET_BOOSTER']), destroyed: true, }); const rules = createRulesHarness({ + crits: [destroyedSuperchargerCrit], inventory: [destroyedSupercharger, destroyedJetBooster], walk: 8, }); @@ -383,7 +435,7 @@ describe('VehicleRules', () => { expect(rules.getEffectiveMaxDistanceForMoveMode('run', turnState(true))).toBe(16); }); - it('keeps active destroyed VTOL Jet Booster effective run MP for the current turn', () => { + it('does not let an active destroyed VTOL Jet Booster provide effective run MP', () => { const jetBooster = entry({ equipment: equipment('ISVTOLJetBooster', ['F_MASC', 'F_JET_BOOSTER']), destroyed: true, @@ -396,7 +448,7 @@ describe('VehicleRules', () => { }); expect(rules.getMaxDistanceForMoveMode('run')).toBe(12); - expect(rules.getEffectiveMaxDistanceForMoveMode('run', turnState(true))).toBe(16); + expect(rules.getEffectiveMaxDistanceForMoveMode('run', turnState(true))).toBe(12); }); it('disables run movement after a flight stabilizer hit', () => { @@ -546,15 +598,14 @@ describe('VehicleRules', () => { { label: 'Sensor hits', modifier: 3, weakened: true }, ]; expect(rules.getBaseGunnerySkill()).toBe(4); - const weaponState = rules.getEquipmentToHit(weaponEntry); - expect(weaponState.modifier).toBe(6); - expect(weaponState.modifiers).toEqual(expectedRangedModifiers); - expect(rules.getBaseGunnerySkill() + weaponState.modifier).toBe(10); - expect(rules.getEquipmentToHit(physicalEntry)).toEqual(jasmine.objectContaining({ - modifiers: [ - { label: 'Commander hit', modifier: 1, weakened: true }, - ], - })); + const weaponModifiers = rules.getEquipmentToHitModifiers(weaponEntry); + const weaponModifierTotal = weaponModifiers.reduce((total, modifier) => total + modifier.modifier, 0); + expect(weaponModifierTotal).toBe(6); + expect(weaponModifiers).toEqual(expectedRangedModifiers); + expect(rules.getBaseGunnerySkill() + weaponModifierTotal).toBe(10); + expect(rules.getEquipmentToHitModifiers(physicalEntry)).toEqual([ + { label: 'Commander hit', modifier: 1, weakened: true }, + ]); }); it('makes drone vehicles Immobile after a commander hit disconnects them', () => { @@ -577,10 +628,12 @@ describe('VehicleRules', () => { expect(rules.hasComputedCondition('disconnected')).toBeTrue(); expect(rules.hasComputedCondition('immobile')).toBeTrue(); expect(rules.movementState()).toEqual(jasmine.objectContaining({ walk: 0, run: 0, moveImpaired: true })); - expect(rules.getEquipmentToHit(weaponEntry).modifier).toBe(0); - expect(rules.getEquipmentToHit(weaponEntry)).toEqual(jasmine.objectContaining({ modifiers: [] })); - expect(rules.getEquipmentToHit(physicalEntry).modifier).toBe(0); - expect(rules.getEquipmentToHit(physicalEntry)).toEqual(jasmine.objectContaining({ modifiers: [] })); + const weaponModifiers = rules.getEquipmentToHitModifiers(weaponEntry); + const physicalModifiers = rules.getEquipmentToHitModifiers(physicalEntry); + expect(weaponModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(0); + expect(weaponModifiers).toEqual([]); + expect(physicalModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(0); + expect(physicalModifiers).toEqual([]); expect(rules.PSRModifiers().modifier).toBe(0); }); @@ -608,8 +661,8 @@ describe('VehicleRules', () => { inventory: [energyEntry, ballisticEntry], }); - expect(rules.getEquipmentStatus(energyEntry)).toBe('disabled'); - expect(rules.getEquipmentStatus(ballisticEntry)).toBe('available'); + expect(energyEntry.owner.getEquipmentStatus(energyEntry)).toBe('disabled'); + expect(ballisticEntry.owner.getEquipmentStatus(ballisticEntry)).toBe('available'); }); it('disables non-physical weapons at Sensor hits level four', () => { @@ -620,8 +673,10 @@ describe('VehicleRules', () => { inventory: [weaponEntry, chargeEntry], }); - expect(rules.getEquipmentStatus(weaponEntry)).toBe('disabled'); - expect(rules.getEquipmentStatus(chargeEntry)).toBe('available'); + expect(weaponEntry.owner.getEquipmentStatus(weaponEntry)).toBe('available'); + expect(chargeEntry.owner.getEquipmentStatus(chargeEntry)).toBe('available'); + expect(weaponEntry.owner.canPerformEquipmentAction(weaponEntry, 'fire')).toBeFalse(); + expect(chargeEntry.owner.canPerformEquipmentAction(chargeEntry, 'physical-attack')).toBeTrue(); }); it('calculates charge damage for core2026 vehicles and preserves TW sheet damage', () => { @@ -652,9 +707,12 @@ describe('VehicleRules', () => { moveMode: 'run', }); - expect(rules.getEquipmentToHit(frontWeapon).modifier).toBe(2); - expect(rules.getEquipmentToHit(rearWeapon).modifier).toBe(0); - expect(rules.getEquipmentToHit(frontRightWeapon).modifier).toBe(2); + const frontModifiers = rules.getEquipmentToHitModifiers(frontWeapon); + const rearModifiers = rules.getEquipmentToHitModifiers(rearWeapon); + const frontRightModifiers = rules.getEquipmentToHitModifiers(frontRightWeapon); + expect(frontModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(2); + expect(rearModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(0); + expect(frontRightModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(2); }); it('reports stabilizer-affected weapons before movement mode is selected', () => { @@ -666,11 +724,13 @@ describe('VehicleRules', () => { moveMode: null, }); - expect(rules.getEquipmentToHit(frontRightWeapon).modifier).toBe(0); - expect(rules.getEquipmentToHit(frontRightWeapon).modifiers).toContain(jasmine.objectContaining({ + const frontRightModifiers = rules.getEquipmentToHitModifiers(frontRightWeapon); + const rearModifiers = rules.getEquipmentToHitModifiers(rearWeapon); + expect(frontRightModifiers.reduce((total, modifier) => total + modifier.modifier, 0)).toBe(0); + expect(frontRightModifiers).toContain(jasmine.objectContaining({ label: 'Stabilizer Hit', modifier: 0, weakened: true, })); - expect(rules.getEquipmentToHit(rearWeapon).modifiers).not.toContain(jasmine.objectContaining({ + expect(rearModifiers).not.toContain(jasmine.objectContaining({ label: 'Stabilizer Hit', })); }); diff --git a/src/app/models/rules/vehicle-rules.ts b/src/app/models/rules/vehicle-rules.ts index 054bc90db..a8eebdce9 100644 --- a/src/app/models/rules/vehicle-rules.ts +++ b/src/app/models/rules/vehicle-rules.ts @@ -3,8 +3,9 @@ // Author: Drake import { computed } from '@angular/core'; -import type { CBTForceUnit } from '../cbt-force-unit.model'; -import type { CrewStateControlDefinition, CrewStateDefinition, UnitConditionControl, MountedEquipmentStatus, UnitRuleModifier } from './unit-type-rules'; +import type { CBTForceUnit, EquipmentAction } from '../cbt-force-unit.model'; +import type { CrewStateControlDefinition, CrewStateDefinition, UnitConditionControl, UnitRuleModifier } from './unit-type-rules'; +import type { EquipmentStatus, EquipmentStatusFacts, UnitSystemStatusFacts } from '../equipment-status.model'; import type { ToHitModifierBreakdownEntry } from './game-rules'; import { crewStateDefinitions, sortPSRModifiers, unitConditionControls, UnitTypeRulesBase } from './unit-type-rules'; import type { PSRCheck, TurnState } from '../turn-state.model'; @@ -88,7 +89,8 @@ export class VehicleRules extends UnitTypeRulesBase { const committed = crits.filter(crit => !!crit.destroyed); const hasCrit = (id: string) => committed.some(crit => this.critId(crit) === id); const hasDroneOperatingSystem = this.hasDroneOperatingSystem(); - const hasWorkingSupercharger = inventory.some(entry => this.isSuperchargerEntry(entry) && !this.isEntryDestroyed(entry)); + const hasWorkingSupercharger = inventory.some(entry => this.isSuperchargerEntry(entry) + && this.unit.canPerformEquipmentAction(entry, 'provide-passive-effect')); const rotorHits = unitType === 'VTOL' ? Math.max(0, this.rotorCommittedCritHits(crits.find(crit => this.critId(crit) === 'rotor'))) : 0; @@ -111,7 +113,7 @@ export class VehicleRules extends UnitTypeRulesBase { commanderHit: !hasDroneOperatingSystem && hasCrit('commander_hit'), copilotHit: !hasDroneOperatingSystem && hasCrit('copilot_hit'), driverOrPilotHit: !hasDroneOperatingSystem && (hasCrit('driver_hit') || hasCrit('pilot_hit')), - engineHit: committed.some(crit => /^engine_hit_\d+$/.test(this.critId(crit))), + engineHit: this.hasCommittedEngineHit(), hasWorkingSupercharger, sensorHits, rotorHits, @@ -263,25 +265,24 @@ export class VehicleRules extends UnitTypeRulesBase { override readonly PSRTargetRoll = computed(() => this.unit.pilotingSkill() + this.PSRModifiers().modifier); - override getEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus { - const status = this.systemsStatus(); - if (this.entryCriticalSlots(entry).some(slot => slot.destroyed) || entry.committedDestroyed()) { - return 'destroyed'; - } - let disabled = this.isEntryStateDisabled(entry); + override getUnitSystemStatusFacts(): UnitSystemStatusFacts { + return { + ...super.getUnitSystemStatusFacts(), + engineHit: this.hasCommittedEngineHit(), + }; + } - if (!this.isPhysicalEntry(entry)) { - if (status.engineHit && entry.equipment?.flags.has('F_ENERGY')) { - disabled = true; - } - if (status.sensorHits >= 4 && entry.equipment instanceof WeaponEquipment) { - disabled = true; - } - } - return disabled ? 'disabled' : 'available'; + override getEquipmentStatusContribution(facts: EquipmentStatusFacts): EquipmentStatus { + return facts.unitSystemFacts.engineHit && facts.equipmentFlags.has('F_ENERGY') + ? 'disabled' + : 'available'; + } + + override canPerformEquipmentAction(entry: MountedEquipment, action: EquipmentAction): boolean { + return action !== 'fire' || entry.isPhysicalWeapon() || this.systemsStatus().sensorHits < 4; } - protected override getEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[] { + override getEquipmentToHitModifiers(entry: MountedEquipment): readonly ToHitModifierBreakdownEntry[] { const status = this.systemsStatus(); const hitModifierBreakdown: ToHitModifierBreakdownEntry[] = []; @@ -333,14 +334,14 @@ export class VehicleRules extends UnitTypeRulesBase { return !!flags?.has('F_MASC'); } - private isEntryDestroyed(entry: MountedEquipment): boolean { - return entry.committedDestroyed() || this.entryCriticalSlots(entry).some(slot => slot.destroyed); - } - private rotorCommittedCritHits(crit: CriticalSlot | undefined): number { return (crit?.hits ?? 0); } + private hasCommittedEngineHit(): boolean { + return this.unit.getCritSlots().some(crit => !!crit.destroyed && /^engine_hit_\d+$/.test(this.critId(crit))); + } + private hasCommittedCrit(id: string): boolean { return this.unit.getCritSlots().some(crit => !!crit.destroyed && this.critId(crit) === id); } diff --git a/src/app/models/turn-state.model.spec.ts b/src/app/models/turn-state.model.spec.ts index 68414eed1..0e6990b6a 100644 --- a/src/app/models/turn-state.model.spec.ts +++ b/src/app/models/turn-state.model.spec.ts @@ -17,6 +17,8 @@ import { PpcCapacitorHandler, PPC_CAPACITOR_STATE_KEY } from '../equipment-handl import { TWAeroRules, TWInfantryRules, TWMekRules } from './rules/tw-rules'; import { CORE_2026_GAME_RULES, TW_GAME_RULES } from './rules/game-rules'; import { EquipmentFlag } from './equipment-flags.type'; +import { EMPTY_EQUIPMENT_REGISTRY } from './equipment-lookup'; +import { createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; interface TurnStateHarnessOptions { critSlots?: CriticalSlot[]; @@ -96,12 +98,24 @@ function createTurnStateHarness(options: TurnStateHarnessOptions = {}): TurnStat getInventory: () => inventory(), getHeat: () => heat(), getEquipmentHeatSources: () => inventory().flatMap(entry => heatSourceHandlers - .flatMap(handler => handler.getInventoryHeatSources?.(entry, turnState) ?? [])), + .flatMap(handler => handler.getInventoryHeatSources?.( + entry, + turnState, + createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY), + ) ?? [])), getRunMovementMultiplierBonus: () => 0, usesForcedWithdrawal: () => true, isInternalLocCommittedDestroyed: (loc: string) => committedDestroyedLegs.has(loc), isInternalLocDestroyed: (loc: string) => currentDestroyedLegs.has(loc) || committedDestroyedLegs.has(loc), - isEquipmentUnavailable: (slot: CriticalSlot) => !!slot.destroyed || (slot.loc ? committedDestroyedLegs.has(slot.loc) : false), + getEquipmentStatus: (source: MountedEquipment | CriticalSlot) => { + if (source instanceof MountedEquipment) return source.committedDestroyed() ? 'destroyed' : 'available'; + return source.destroyed || (source.loc ? committedDestroyedLegs.has(source.loc) : false) + ? 'destroyed' + : 'available'; + }, + isEquipmentOperational: (source: MountedEquipment | CriticalSlot) => source instanceof MountedEquipment + ? !source.committedDestroyed() + : !source.destroyed && !(source.loc && committedDestroyedLegs.has(source.loc)), getRuleCheck: (key: string) => ruleChecks.get(key), setRuleCheck: (key: string, check: { token: string; trigger: string; status: 'pending' | 'success' | 'failed' } | undefined) => { if (check) ruleChecks.set(key, check); @@ -978,4 +992,4 @@ describe('TurnState', () => { expect(turnState.heatSources().some(source => source.id.startsWith('ppc-capacitor:'))).toBeFalse(); }); }); -}); \ No newline at end of file +}); diff --git a/src/app/services/equipment-interaction-registry.service.spec.ts b/src/app/services/equipment-interaction-registry.service.spec.ts index d4feae10c..264c09eda 100644 --- a/src/app/services/equipment-interaction-registry.service.spec.ts +++ b/src/app/services/equipment-interaction-registry.service.spec.ts @@ -5,14 +5,30 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { ApolloHandler } from '../equipment-handlers/apollo.handler'; import { AtmHandler } from '../equipment-handlers/atm.handler'; +import { C3Handler } from '../equipment-handlers/c3.handler'; +import { WeaponAmmoHandler } from '../equipment-handlers/weapon-ammo.handler'; import { InventoryModeHandler, INVENTORY_MODE_HANDLER_ID } from '../equipment-handlers/inventory-mode.handler'; -import { type Equipment, WeaponEquipment } from '../models/equipment.model'; +import { MascHandler } from '../equipment-handlers/masc.handler'; +import type { EquipmentAction, EquipmentStateEdit } from '../models/cbt-force-unit.model'; +import { AmmoEquipment, type Equipment, WeaponEquipment } from '../models/equipment.model'; +import type { EquipmentStatus } from '../models/equipment-status.model'; import { EquipmentRegistry } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; -import { TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; -import { createEmptyUnit, createTestEquipmentRules } from '../testing/unit-test-helpers'; -import { EquipmentInteractionHandler, EquipmentInteractionRegistryService, type HandlerContext } from './equipment-interaction-registry.service'; +import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; +import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../models/rules/unit-type-rules'; +import { createEmptyUnit } from '../testing/unit-test-helpers'; +import { + createHandlerCommandContext, + createHandlerQueryContext, + EquipmentInteractionHandler, + EquipmentInteractionRegistryService, + type HandlerCommandContext, + type HandlerDialogsService, + type HandlerQueryContext, + type HandlerToastService, +} from './equipment-interaction-registry.service'; import type { Force } from '../models/force.model'; +import { of } from 'rxjs'; function svgEntry(html: string): SVGElement { const wrapper = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); @@ -20,18 +36,23 @@ function svgEntry(html: string): SVGElement { return wrapper.firstElementChild as SVGElement; } -function owner(gameRules?: CBTGameRules): never { +function owner(gameRules?: CBTGameRules, operational = true, readOnly = false): never { return { gameRules, getUnit: () => createEmptyUnit(), - isEquipmentActionUnavailable: () => false, - rules: createTestEquipmentRules(), + getEquipmentStatus: () => operational ? 'available' : 'destroyed', + getEquipmentStatusAtLocation: () => operational ? 'available' : 'destroyed', + getInventoryControlSelectedAmmo: () => null, + matchesInventoryControlAmmo: () => null, + isEquipmentOperational: () => operational, + canPerformEquipmentAction: () => operational, + readOnly: () => readOnly, } as never; } -function atmEntry(): MountedEquipment { +function atmEntry(entryOwner: never = owner()): MountedEquipment { return new MountedEquipment({ - owner: owner(), + owner: entryOwner, id: 'ATM12@RA#1', name: 'ATM 12', equipment: new WeaponEquipment({ id: 'ATM12', name: 'ATM 12', type: 'weapon', weapon: { ammoType: 'ATM', rackSize: 12 } }), @@ -44,24 +65,63 @@ function atmEntry(): MountedEquipment { }); } -function context(): HandlerContext { - return { - dataService: { - getEquipmentRegistry: () => new EquipmentRegistry({}), +function mascEntry(readOnly = false): MountedEquipment { + const getEquipmentStatus = (candidate: MountedEquipment): EquipmentStatus => candidate.committedDestroyed() + ? 'destroyed' + : candidate.states.get(ENTRY_DISABLED_STATE_KEY) === ENTRY_DISABLED_STATE_VALUE + ? 'disabled' + : 'available'; + const mascOwner = { + gameRules: CORE_2026_GAME_RULES, + getEquipmentStatus, + isEquipmentOperational: (candidate: MountedEquipment) => getEquipmentStatus(candidate) === 'available', + canPerformEquipmentAction: (candidate: MountedEquipment) => getEquipmentStatus(candidate) === 'available', + canEditEquipmentState: (candidate: MountedEquipment, edit: EquipmentStateEdit) => { + if (readOnly) return false; + const status = getEquipmentStatus(candidate); + if (edit === 'enable') return status === 'disabled'; + if (edit === 'disable') return status === 'available'; + return false; }, - dialogsService: {}, - toastService: {} - } as unknown as HandlerContext; + readOnly: () => readOnly, + getNotificationDisplayName: () => 'Test Vehicle', + setInventoryEntry: jasmine.createSpy('setInventoryEntry'), + turnState: () => ({ airborne: () => null }), + }; + return new MountedEquipment({ + owner: mascOwner as never, + id: 'masc', + name: 'MASC', + equipment: { name: 'MASC', flags: new Set(['F_MASC']) } as Equipment, + }); +} + +function queryContext(equipmentCatalog = new EquipmentRegistry({})): HandlerQueryContext { + return createHandlerQueryContext(equipmentCatalog); +} + +function commandContext( + equipmentCatalog = new EquipmentRegistry({}), + dialogsService = jasmine.createSpyObj( + 'HandlerDialogsService', + ['createDialog', 'showError', 'showNoticeHtml'], + ), +): HandlerCommandContext { + const toastService = jasmine.createSpyObj( + 'HandlerToastService', + ['showToast', 'toasts'], + ); + return createHandlerCommandContext(equipmentCatalog, toastService, dialogsService); } class ExtraDropdownHandler extends EquipmentInteractionHandler { readonly id = 'extra-dropdown-handler'; - getChoices(_equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(_equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { return [{ label: 'Extra', value: 'one', displayType: 'dropdown', choices: [{ label: 'One', value: 'one' }] }]; } - handleSelection(_equipment: MountedEquipment, _value: PickerChoice, _context: HandlerContext): boolean { + handleSelection(_equipment: MountedEquipment, _value: PickerChoice, _context: HandlerCommandContext): boolean { return true; } } @@ -69,11 +129,11 @@ class ExtraDropdownHandler extends EquipmentInteractionHandler { class SelectionHandler extends EquipmentInteractionHandler { readonly id = 'selection-handler'; - getChoices(_equipment: MountedEquipment, _context: HandlerContext): PickerChoice[] { + getChoices(_equipment: MountedEquipment, _context: HandlerQueryContext): PickerChoice[] { return [{ label: 'Select', value: 'select' }]; } - handleSelection(_equipment: MountedEquipment, _value: PickerChoice, _context: HandlerContext): boolean { + handleSelection(_equipment: MountedEquipment, _value: PickerChoice, _context: HandlerCommandContext): boolean { return true; } } @@ -117,6 +177,58 @@ class DamageHandler extends EquipmentInteractionHandler { } describe('EquipmentInteractionRegistryService', () => { + it('creates pure query contexts from the equipment catalog without exposing DataService', () => { + const equipmentCatalog = new EquipmentRegistry({}); + + const queryContext = createHandlerQueryContext(equipmentCatalog, 'inventory'); + + expect(queryContext).toEqual(jasmine.objectContaining({ equipmentCatalog, choiceSurface: 'inventory' })); + expect(typeof queryContext.getStatus).toBe('function'); + expect(typeof queryContext.matchesAmmo).toBe('function'); + expect(typeof queryContext.canProvidePassiveEffect).toBe('function'); + expect(typeof queryContext.isReadOnly).toBe('function'); + expect('dataService' in queryContext).toBeFalse(); + }); + + it('derives each canonical query from the mounted equipment owner', () => { + const equipmentCatalog = new EquipmentRegistry({}); + const getEquipmentStatus = jasmine.createSpy('getEquipmentStatus').and.returnValue('disabled'); + const matchesInventoryControlAmmo = jasmine.createSpy('matchesInventoryControlAmmo').and.returnValue(true); + const canPerformEquipmentAction = jasmine.createSpy('canPerformEquipmentAction').and.returnValue(false); + const readOnly = jasmine.createSpy('readOnly').and.returnValue(true); + const entry = new MountedEquipment({ + owner: { + getEquipmentStatus, + matchesInventoryControlAmmo, + canPerformEquipmentAction, + readOnly, + } as never, + id: 'query-entry', + name: 'Query Entry', + }); + const queryContext = createHandlerQueryContext(equipmentCatalog); + + expect(queryContext.getStatus(entry)).toBe('disabled'); + expect(queryContext.matchesAmmo(entry, {} as never, 'LRM')).toBeTrue(); + expect(queryContext.canProvidePassiveEffect(entry)).toBeFalse(); + expect(queryContext.isReadOnly(entry)).toBeTrue(); + expect(getEquipmentStatus).toHaveBeenCalledOnceWith(entry); + expect(matchesInventoryControlAmmo).toHaveBeenCalledOnceWith(entry, {} as never, 'LRM'); + expect(canPerformEquipmentAction).toHaveBeenCalledOnceWith(entry, 'provide-passive-effect'); + expect(readOnly).toHaveBeenCalledOnceWith(); + }); + + it('creates command contexts from narrow services without exposing DataService', () => { + const equipmentCatalog = new EquipmentRegistry({}); + const toastService = { showToast: jasmine.createSpy('showToast') } as never; + const dialogsService = {} as never; + + const commandContext = createHandlerCommandContext(equipmentCatalog, toastService, dialogsService); + + expect(commandContext).toEqual({ equipmentCatalog, toastService, dialogsService }); + expect('dataService' in commandContext).toBeFalse(); + }); + it('keeps SVG-owned mode choices with specialized ammo handlers present', () => { const registry = new EquipmentInteractionRegistryService().getRegistry(); registry.register(new InventoryModeHandler()); @@ -129,7 +241,7 @@ describe('EquipmentInteractionRegistryService', () => { expect(handlers).toContain('extra-dropdown-handler'); expect(handlers).toContain(INVENTORY_MODE_HANDLER_ID); - const choices = registry.getChoices(atmEntry(), context()); + const choices = registry.getChoices(atmEntry(), queryContext()); const modeChoices = choices.filter(choice => choice.label === 'Mode' && choice.displayType === 'dropdown'); expect(modeChoices.length).toBe(1); expect(modeChoices[0]._handler?.id).toBe(INVENTORY_MODE_HANDLER_ID); @@ -161,21 +273,21 @@ describe('EquipmentInteractionRegistryService', () => { const entry = atmEntry(); registry.register(new SelectionHandler()); - const choice = registry.getChoices(entry, context())[0]; + const choice = registry.getChoices(entry, queryContext())[0]; - expect(registry.handleSelection(entry, choice, context())).toBeTrue(); + expect(registry.handleSelection(entry, choice, commandContext())).toBeTrue(); }); it('dispatches force runtime changes generically to interested handlers', () => { const registry = new EquipmentInteractionRegistryService().getRegistry(); const handler = new ForceRuntimeHandler(); const force = {} as Force; - const handlerContext = context(); + const notifications = commandContext().toastService; registry.register(handler); - registry.onForceRuntimeChanged(force, handlerContext); + registry.onForceRuntimeChanged(force, notifications); - expect(handler.onForceRuntimeChanged).toHaveBeenCalledOnceWith(force, handlerContext); + expect(handler.onForceRuntimeChanged).toHaveBeenCalledOnceWith(force, notifications); }); it('disables and rejects equipment actions when the owning unit cannot operate', () => { @@ -183,16 +295,192 @@ describe('EquipmentInteractionRegistryService', () => { const handler = new SelectionHandler(); const selection = spyOn(handler, 'handleSelection').and.callThrough(); const entry = atmEntry(); - entry.owner.isEquipmentActionUnavailable = () => true; + entry.owner.canPerformEquipmentAction = () => false; registry.register(handler); - const choice = registry.getChoices(entry, context())[0]; + const choice = registry.getChoices(entry, queryContext())[0]; expect(choice.disabled).toBeTrue(); - expect(registry.handleSelection(entry, choice, context())).toBeFalse(); + expect(registry.handleSelection(entry, choice, commandContext())).toBeFalse(); expect(selection).not.toHaveBeenCalled(); }); + it('rejects read-only commands without gating pure projections', () => { + const registry = new EquipmentInteractionRegistryService().getRegistry(); + const selectionHandler = new SelectionHandler(); + const selection = spyOn(selectionHandler, 'handleSelection').and.callThrough(); + const entry = atmEntry(owner(undefined, true, true)); + registry.register(selectionHandler); + registry.register(new DamageHandler('projection', 2, 1)); + + const choice = registry.getChoices(entry, queryContext())[0]; + + expect(choice.disabled).toBeTrue(); + expect(registry.handleSelection(entry, choice, commandContext())).toBeFalse(); + expect(selection).not.toHaveBeenCalled(); + expect(registry.applyInventoryControlDamageEffects( + entry, + { values: [5], maximum: 5, unit: 'shot' }, + {} as never, + queryContext(), + )).toEqual({ values: [7], maximum: 7, unit: 'shot' }); + }); + + it('rejects read-only MASC sequence changes from the turn-summary choice surface', () => { + const registry = new EquipmentInteractionRegistryService().getRegistry(); + const entry = mascEntry(true); + registry.register(new MascHandler()); + const queryContext = createHandlerQueryContext(new EquipmentRegistry({}), 'turn-summary'); + + const sequenceChoice = registry.getChoices(entry, queryContext) + .find(choice => typeof choice.value === 'number')!; + + expect(sequenceChoice.disabled).toBeTrue(); + expect(registry.handleSelection(entry, sequenceChoice, commandContext())).toBeFalse(); + expect(entry.states.size).toBe(0); + expect(entry.owner.setInventoryEntry).not.toHaveBeenCalled(); + }); + + it('opens the read-only ammo dialog while keeping ammo mutations unavailable', async () => { + const registry = new EquipmentInteractionRegistryService().getRegistry(); + const ammo = new AmmoEquipment({ + id: 'LRM 10 Ammo', + name: 'LRM 10 Ammo', + type: 'ammo', + ammo: { type: 'LRM', rackSize: 10, shots: 12 }, + }); + let inventory: MountedEquipment[] = []; + const ammoOwner = { + readOnly: () => true, + getCritSlots: () => [], + getInventory: () => inventory, + isEquipmentOperational: () => true, + canPerformEquipmentAction: () => true, + } as never; + const weapon = new MountedEquipment({ + owner: ammoOwner, + id: 'lrm-10', + name: 'LRM 10', + equipment: new WeaponEquipment({ + id: 'LRM 10', name: 'LRM 10', type: 'weapon', + weapon: { ammoType: 'LRM', rackSize: 10 }, + }), + }); + const ammoMount = new MountedEquipment({ + owner: ammoOwner, + id: 'lrm-ammo', + name: 'LRM 10 Ammo', + equipment: ammo, + totalAmmo: 12, + }); + inventory = [weapon, ammoMount]; + const equipmentCatalog = new EquipmentRegistry({ [ammo.internalName]: ammo }); + const dialogsService = jasmine.createSpyObj( + 'HandlerDialogsService', + ['createDialog', 'showError', 'showNoticeHtml'], + ); + const handlerQueryContext = queryContext(equipmentCatalog); + const handlerCommandContext = commandContext(equipmentCatalog, dialogsService); + registry.register(new WeaponAmmoHandler()); + + const choice = registry.getChoices(weapon, handlerQueryContext)[0]; + + expect(choice).toEqual(jasmine.objectContaining({ + value: 'weapon-ammo-dialog', readOnlySafe: true, disabled: false, + })); + await expectAsync(Promise.resolve(registry.handleSelection(weapon, choice, handlerCommandContext))).toBeResolvedTo(true); + expect(dialogsService.createDialog).toHaveBeenCalledWith(jasmine.any(Function), jasmine.objectContaining({ + data: jasmine.objectContaining({ readOnly: true, initialTab: 'ammo' }), + })); + }); + + it('opens read-only C3 configuration but ignores an updated dialog result', async () => { + const registry = new EquipmentInteractionRegistryService().getRegistry(); + const setNetwork = jasmine.createSpy('setNetwork'); + const force = { setNetwork } as never; + const entry = new MountedEquipment({ + owner: { + force, + readOnly: () => true, + canPerformEquipmentAction: (_entry: MountedEquipment, action: EquipmentAction) => action === 'configure-network', + canEditEquipmentState: () => false, + } as never, + id: 'read-only-c3', + name: 'C3 Master', + equipment: { flags: new Set(['ANY_C3']) } as Equipment, + }); + const equipmentCatalog = new EquipmentRegistry({}); + const dialogsService = jasmine.createSpyObj( + 'HandlerDialogsService', + ['createDialog', 'showError', 'showNoticeHtml'], + ); + dialogsService.createDialog.and.returnValue({ + closed: of({ updated: true, networks: [{ id: 'forged-result' }] }), + } as never); + const handlerQueryContext = queryContext(equipmentCatalog); + const handlerCommandContext = commandContext(equipmentCatalog, dialogsService); + registry.register(new C3Handler()); + + const choice = registry.getChoices(entry, handlerQueryContext)[0]; + + expect(choice).toEqual(jasmine.objectContaining({ + action: 'configure-network', readOnlySafe: true, disabled: false, + })); + await expectAsync(Promise.resolve(registry.handleSelection(entry, choice, handlerCommandContext))).toBeResolvedTo(true); + expect(dialogsService.createDialog).toHaveBeenCalledWith(jasmine.any(Function), jasmine.objectContaining({ + data: jasmine.objectContaining({ readOnly: true }), + })); + expect(setNetwork).not.toHaveBeenCalled(); + }); + + it('routes C3 configuration through the configure-network action policy', async () => { + const registry = new EquipmentInteractionRegistryService().getRegistry(); + const canPerformEquipmentAction = jasmine.createSpy('canPerformEquipmentAction') + .and.callFake((_entry: MountedEquipment, action: EquipmentAction) => action === 'configure-network'); + const entry = new MountedEquipment({ + owner: { + canPerformEquipmentAction, + canEditEquipmentState: () => false, + readOnly: () => false, + } as never, + id: 'c3-master', + name: 'C3 Master', + equipment: { flags: new Set(['ANY_C3']) } as Equipment, + }); + const handler = new C3Handler(); + const selection = spyOn(handler, 'handleSelection').and.resolveTo(true); + registry.register(handler); + + const choice = registry.getChoices(entry, queryContext())[0]; + + expect(choice).toEqual(jasmine.objectContaining({ action: 'configure-network', disabled: false })); + expect(canPerformEquipmentAction).toHaveBeenCalledOnceWith(entry, 'configure-network'); + canPerformEquipmentAction.calls.reset(); + + await expectAsync(Promise.resolve(registry.handleSelection(entry, choice, commandContext()))).toBeResolvedTo(true); + expect(canPerformEquipmentAction).toHaveBeenCalledOnceWith(entry, 'configure-network'); + expect(selection).toHaveBeenCalledOnceWith(entry, choice, jasmine.any(Object)); + }); + + it('routes MASC disable and enable state edits through the production registry', () => { + const registry = new EquipmentInteractionRegistryService().getRegistry(); + const entry = mascEntry(); + registry.register(new MascHandler()); + + const disableChoice = registry.getChoices(entry, queryContext()).at(-1)!; + + expect(disableChoice).toEqual(jasmine.objectContaining({ stateEdit: 'disable', disabled: false })); + expect(registry.handleSelection(entry, disableChoice, commandContext())).toBeTrue(); + expect(entry.owner.getEquipmentStatus(entry)).toBe('disabled'); + expect(entry.owner.canPerformEquipmentAction(entry, 'change-mode')).toBeFalse(); + + const enableChoice = registry.getChoices(entry, queryContext()).at(-1)!; + + expect(enableChoice).toEqual(jasmine.objectContaining({ stateEdit: 'enable', disabled: false })); + expect(registry.handleSelection(entry, enableChoice, commandContext())).toBeTrue(); + expect(entry.owner.getEquipmentStatus(entry)).toBe('available'); + }); + it('composes structured damage by priority without mutating the input', () => { const registry = new EquipmentInteractionRegistryService().getRegistry(); registry.register(new DamageHandler('late', 10, 1)); @@ -200,7 +488,7 @@ describe('EquipmentInteractionRegistryService', () => { const input = { values: [5] as const, maximum: 10, unit: 'shot' as const }; const result = registry.applyInventoryControlDamageEffects( - atmEntry(), input, {} as never, context(), + atmEntry(), input, {} as never, queryContext(), ); expect(result).toEqual({ values: [16], maximum: 21, unit: 'shot' }); @@ -230,7 +518,7 @@ describe('EquipmentInteractionRegistryService', () => { linkedWith: [apollo] }); - const adjustments = registry.getToHitAdjustments(mrm, context()); + const adjustments = registry.getToHitAdjustments(mrm, queryContext()); expect(adjustments).toEqual([{ kind: 'add', label: 'Apollo', modifier: -1, weakened: false }]); @@ -241,17 +529,11 @@ describe('EquipmentInteractionRegistryService', () => { const registry = new EquipmentInteractionRegistryService().getRegistry(); registry.register(new ApolloHandler()); const apollo = new MountedEquipment({ - owner: owner(TW_GAME_RULES), + owner: owner(TW_GAME_RULES, false), id: 'apollo', name: 'Apollo', equipment: { flags: new Set(['F_WEAPON_ENHANCEMENT', 'F_APOLLO']) } as Equipment }); - apollo.owner = { - ...apollo.owner, - rules: createTestEquipmentRules({ - getEquipmentStatus: (candidate: MountedEquipment) => candidate === apollo ? 'destroyed' : 'available', - }) - } as never; const mrm = new MountedEquipment({ owner: owner(TW_GAME_RULES), id: 'mrm', @@ -264,7 +546,7 @@ describe('EquipmentInteractionRegistryService', () => { linkedWith: [apollo] }); - const adjustments = registry.getToHitAdjustments(mrm, context()); + const adjustments = registry.getToHitAdjustments(mrm, queryContext()); expect(adjustments).toEqual([{ kind: 'add', label: 'Apollo Destroyed', modifier: 0, weakened: true }]); diff --git a/src/app/services/equipment-interaction-registry.service.ts b/src/app/services/equipment-interaction-registry.service.ts index 826555f41..408b94e76 100644 --- a/src/app/services/equipment-interaction-registry.service.ts +++ b/src/app/services/equipment-interaction-registry.service.ts @@ -5,12 +5,11 @@ import { Injectable } from '@angular/core'; import type { PickerChoice, PickerValue } from '../components/picker/picker.interface'; import type { MountedEquipment } from '../models/mounted-equipment.model'; -import type { ToastService } from './toast.service'; +import type { Toast, ToastService } from './toast.service'; import type { DialogsService } from './dialogs.service'; -import type { DataService } from './data.service'; 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 { InventoryControlAmmoMatcher, InventoryControlDisplayData, InventoryControlDisplayEffectOptions, InventoryControlRules } from '../utils/inventory-control.util'; import type { WeaponDamage } from '../models/equipment.model'; import type { InventoryControlDamageContext } from '../utils/inventory-control-damage.util'; import type { TurnState } from '../models/turn-state.model'; @@ -20,15 +19,60 @@ import type { InventoryControlHeatEffect } from '../utils/inventory-control-heat import type { InventoryControlPhysicalDamageEffect } from '../utils/inventory-control-physical-damage.util'; import { EquipmentFlag } from '../models/equipment-flags.type'; import type { Force } from '../models/force.model'; +import type { EquipmentAction, EquipmentStateEdit } from '../models/cbt-force-unit.model'; +import type { EquipmentRegistry } from '../models/equipment-lookup'; +import type { EquipmentStatus } from '../models/equipment-status.model'; + +export interface HandlerQueryContext { + readonly equipmentCatalog: EquipmentRegistry; + readonly getStatus: (equipment: MountedEquipment) => EquipmentStatus; + readonly matchesAmmo: InventoryControlAmmoMatcher; + readonly canProvidePassiveEffect: (equipment: MountedEquipment) => boolean; + readonly isReadOnly: (equipment: MountedEquipment) => boolean; + readonly choiceSurface?: 'critical' | 'inventory' | 'turn-summary'; +} -/** - * Context passed to handlers containing additional information - */ -export interface HandlerContext { - toastService: ToastService; - dialogsService: DialogsService; - dataService: DataService; - choiceSurface?: 'critical' | 'inventory' | 'turn-summary'; +export function createHandlerQueryContext( + equipmentCatalog: EquipmentRegistry, + choiceSurface?: HandlerQueryContext['choiceSurface'] +): HandlerQueryContext { + const context: HandlerQueryContext = { + equipmentCatalog, + getStatus: equipment => equipment.owner.getEquipmentStatus(equipment), + matchesAmmo: (equipment, ammo, mode) => equipment.owner.matchesInventoryControlAmmo(equipment, ammo, mode), + canProvidePassiveEffect: equipment => equipment.owner.canPerformEquipmentAction(equipment, 'provide-passive-effect'), + isReadOnly: equipment => equipment.owner.readOnly(), + }; + return choiceSurface === undefined + ? context + : { ...context, choiceSurface }; +} + +export interface HandlerCommandContext { + readonly equipmentCatalog: EquipmentRegistry; + readonly toastService: HandlerToastService; + readonly dialogsService: HandlerDialogsService; +} + +export interface HandlerToastService { + showToast: ToastService['showToast']; + toasts(): readonly Toast[]; +} + +export type HandlerNotifications = Pick; + +export interface HandlerDialogsService { + createDialog: DialogsService['createDialog']; + showError: DialogsService['showError']; + showNoticeHtml: DialogsService['showNoticeHtml']; +} + +export function createHandlerCommandContext( + equipmentCatalog: EquipmentRegistry, + toastService: HandlerToastService, + dialogsService: HandlerDialogsService +): HandlerCommandContext { + return { equipmentCatalog, toastService, dialogsService }; } /** @@ -37,6 +81,12 @@ export interface HandlerContext { export interface HandlerChoice extends PickerChoice { /** Internal identifier linking this choice to its handler */ _handler?: EquipmentInteractionHandler; + /** Concrete operational permission; ordinary mode choices default to `change-mode`. */ + action?: EquipmentAction; + /** Recovery/state edit uses explicit edit permission instead of operational gating. */ + stateEdit?: EquipmentStateEdit; + /** Non-mutating navigation that remains useful on a read-only unit. */ + readOnlySafe?: boolean; } export interface ToHitAdjustmentContext { @@ -74,7 +124,7 @@ export abstract class EquipmentInteractionHandler { * @param context Additional context information * @returns Array of picker choices, or null if this handler doesn't apply */ - abstract getChoices(equipment: MountedEquipment, context: HandlerContext): PickerChoice[] | null; + abstract getChoices(equipment: MountedEquipment, context: HandlerQueryContext): HandlerChoice[] | null; /** * Handles the selection of a choice @@ -83,20 +133,25 @@ export abstract class EquipmentInteractionHandler { * @param context Additional context information * @returns true if the picker should close, false to keep it open (can be async) */ - abstract handleSelection(equipment: MountedEquipment, value: PickerChoice, context: HandlerContext): boolean | Promise; + abstract handleSelection(equipment: MountedEquipment, value: PickerChoice, context: HandlerCommandContext): boolean | Promise; /** * Hook called after a mounted equipment entry is fired/consumed from the weapons panel. */ - afterInventoryControlFire?(equipment: MountedEquipment, context: HandlerContext): void | Promise; + afterInventoryControlFire?(equipment: MountedEquipment): void | Promise; + + /** + * Hook called immediately before pending equipment and critical-slot damage is committed. + */ + beforeEquipmentStateCommit?(equipment: MountedEquipment): void; /** * Hook called when the owning unit ends its turn. */ - onEndTurn?(equipment: MountedEquipment, context: HandlerContext): void; + onEndTurn?(equipment: MountedEquipment, notifications: HandlerNotifications): void; /** Hook called when a loaded force's reactive runtime state changes. */ - onForceRuntimeChanged?(force: Force, context: HandlerContext): void; + onForceRuntimeChanged?(force: Force, notifications: HandlerNotifications): void; /** * Hook called while building an inventory-control row display. @@ -105,7 +160,7 @@ export abstract class EquipmentInteractionHandler { equipment: MountedEquipment, display: InventoryControlDisplayData, options: InventoryControlDisplayEffectOptions, - context: HandlerContext + context: HandlerQueryContext ): InventoryControlDisplayData; /** @@ -115,21 +170,21 @@ export abstract class EquipmentInteractionHandler { equipment: MountedEquipment, damage: WeaponDamage, damageContext: InventoryControlDamageContext, - context: HandlerContext + context: HandlerQueryContext ): WeaponDamage; /** Applies equipment mode/state to a physical weapon's base damage policy. */ applyInventoryControlPhysicalDamageEffects?( equipment: MountedEquipment, effect: InventoryControlPhysicalDamageEffect, - context: HandlerContext + context: HandlerQueryContext ): InventoryControlPhysicalDamageEffect; /** Applies equipment-state modifiers to typed weapon firing heat. */ - applyInventoryControlHeatEffects?(equipment: MountedEquipment, effect: InventoryControlHeatEffect, context: HandlerContext): InventoryControlHeatEffect; + applyInventoryControlHeatEffects?(equipment: MountedEquipment, effect: InventoryControlHeatEffect, context: HandlerQueryContext): InventoryControlHeatEffect; /** Supplies typed selectable heat for physical or miscellaneous equipment. */ - getInventoryControlHeatEffect?(equipment: MountedEquipment, context: HandlerContext): InventoryControlHeatEffect | null; + getInventoryControlHeatEffect?(equipment: MountedEquipment, context: HandlerQueryContext): InventoryControlHeatEffect | null; /** * Hook called for linked equipment while building a parent entry's inventory-control row display. @@ -139,7 +194,7 @@ export abstract class EquipmentInteractionHandler { parent: MountedEquipment, display: InventoryControlDisplayData, options: InventoryControlDisplayEffectOptions, - context: HandlerContext + context: HandlerQueryContext ): InventoryControlDisplayData; /** Applies a linked enhancement's modifiers to typed weapon firing heat. */ @@ -147,14 +202,14 @@ export abstract class EquipmentInteractionHandler { equipment: MountedEquipment, parent: MountedEquipment, effect: InventoryControlHeatEffect, - context: HandlerContext + context: HandlerQueryContext ): InventoryControlHeatEffect; /** Adds or removes effective weapon types based on the weapon's own state. */ applyInventoryControlWeaponTypes?( equipment: MountedEquipment, types: ReadonlySet, - context: HandlerContext + context: HandlerQueryContext ): ReadonlySet; /** @@ -164,38 +219,46 @@ export abstract class EquipmentInteractionHandler { equipment: MountedEquipment, parent: MountedEquipment, types: ReadonlySet, - context: HandlerContext + context: HandlerQueryContext ): ReadonlySet; /** * Hook called while filtering ammo options for a selected inventory-control mode. */ - matchesInventoryAmmo?(equipment: MountedEquipment, ammo: AmmoEquipment, mode: string | null, context: HandlerContext): boolean | null; + matchesInventoryAmmo?(equipment: MountedEquipment, ammo: AmmoEquipment, mode: string | null, context: HandlerQueryContext): boolean | null; /** Returns typed adjustments to an entry's effective to-hit profile. */ getToHitAdjustments?( equipment: MountedEquipment, adjustmentContext: ToHitAdjustmentContext, - context: HandlerContext + context: HandlerQueryContext ): readonly ToHitAdjustment[]; /** * Hook called while collecting turn heat sources from inventory entries. */ - getInventoryHeatSources?(equipment: MountedEquipment, turnState: TurnState): UnitHeatSource[]; + getInventoryHeatSources?( + equipment: MountedEquipment, + turnState: TurnState, + context: HandlerQueryContext + ): UnitHeatSource[]; /** * Hook called while calculating active run movement multiplier bonuses. */ - getRunMovementMultiplierBonus?(equipment: MountedEquipment, turnState: TurnState): number; + getRunMovementMultiplierBonus?( + equipment: MountedEquipment, + turnState: TurnState, + context: HandlerQueryContext + ): number; /** * Hook called when equipment-specific modes can veto aimed shots. */ - canPerformAimedShot?(equipment: MountedEquipment, context: HandlerContext): boolean | null; + canPerformAimedShot?(equipment: MountedEquipment, context: HandlerQueryContext): boolean | null; /** Equipment-specific veto for selecting an inventory entry to fire. */ - isInventoryControlSelectable?(equipment: MountedEquipment, context: HandlerContext): boolean | null; + isInventoryControlSelectable?(equipment: MountedEquipment, context: HandlerQueryContext): boolean | null; } /** @@ -268,10 +331,9 @@ export class EquipmentInteractionRegistry { /** * Generate all choices for an equipment, tagged with handler IDs */ - getChoices(equipment: MountedEquipment, context: HandlerContext): HandlerChoice[] { + getChoices(equipment: MountedEquipment, context: HandlerQueryContext): HandlerChoice[] { const handlers = this.getHandlers(equipment); const allChoices: HandlerChoice[] = []; - const actionUnavailable = equipment.isActionUnavailable(); for (const handler of handlers) { const choices = handler.getChoices(equipment, context); @@ -279,7 +341,7 @@ export class EquipmentInteractionRegistry { // Tag each choice with the handler ID const taggedChoices = choices.map(choice => ({ ...choice, - disabled: actionUnavailable || choice.disabled, + disabled: choice.disabled || !this.canDispatchChoice(equipment, choice), _handler: handler })); allChoices.push(...taggedChoices); @@ -295,31 +357,43 @@ export class EquipmentInteractionRegistry { handleSelection( equipment: MountedEquipment, choice: HandlerChoice, - context: HandlerContext + context: HandlerCommandContext ): boolean | Promise { - const actionUnavailable = equipment.isActionUnavailable(); - if (!choice._handler || choice.disabled || actionUnavailable) { + if (!choice._handler || choice.disabled || !this.canDispatchChoice(equipment, choice)) { return false; } return choice._handler.handleSelection(equipment, choice, context); } - async afterInventoryControlFire(equipment: MountedEquipment, context: HandlerContext): Promise { + private canDispatchChoice(equipment: MountedEquipment, choice: HandlerChoice): boolean { + if (equipment.owner.readOnly() && !choice.readOnlySafe) return false; + return choice.stateEdit + ? equipment.owner.canEditEquipmentState(equipment, choice.stateEdit) + : equipment.owner.canPerformEquipmentAction(equipment, choice.action ?? 'change-mode'); + } + + async afterInventoryControlFire(equipment: MountedEquipment): Promise { + for (const handler of this.getHandlers(equipment)) { + await handler.afterInventoryControlFire?.(equipment); + } + } + + beforeEquipmentStateCommit(equipment: MountedEquipment): void { for (const handler of this.getHandlers(equipment)) { - await handler.afterInventoryControlFire?.(equipment, context); + handler.beforeEquipmentStateCommit?.(equipment); } } - onEndTurn(equipment: MountedEquipment, context: HandlerContext): void { + onEndTurn(equipment: MountedEquipment, notifications: HandlerNotifications): void { for (const handler of this.getHandlers(equipment)) { - handler.onEndTurn?.(equipment, context); + handler.onEndTurn?.(equipment, notifications); } } - onForceRuntimeChanged(force: Force, context: HandlerContext): void { + onForceRuntimeChanged(force: Force, notifications: HandlerNotifications): void { for (const handler of this.handlers.values()) { - handler.onForceRuntimeChanged?.(force, context); + handler.onForceRuntimeChanged?.(force, notifications); } } @@ -327,7 +401,7 @@ export class EquipmentInteractionRegistry { equipment: MountedEquipment, display: InventoryControlDisplayData, options: InventoryControlDisplayEffectOptions, - context: HandlerContext + context: HandlerQueryContext ): InventoryControlDisplayData { let nextDisplay = display; for (const handler of this.getHandlers(equipment)) { @@ -344,7 +418,7 @@ export class EquipmentInteractionRegistry { applyWeaponTypes( equipment: MountedEquipment, types: ReadonlySet, - context: HandlerContext + context: HandlerQueryContext ): ReadonlySet { let nextTypes = types; for (const handler of this.getHandlers(equipment)) { @@ -362,7 +436,7 @@ export class EquipmentInteractionRegistry { equipment: MountedEquipment, damage: WeaponDamage, damageContext: InventoryControlDamageContext, - context: HandlerContext + context: HandlerQueryContext ): WeaponDamage { let nextDamage = damage; for (const handler of this.getHandlers(equipment)) { @@ -374,7 +448,7 @@ export class EquipmentInteractionRegistry { applyInventoryControlPhysicalDamageEffects( equipment: MountedEquipment, effect: InventoryControlPhysicalDamageEffect, - context: HandlerContext + context: HandlerQueryContext ): InventoryControlPhysicalDamageEffect { let nextEffect = effect; for (const handler of this.getHandlers(equipment)) { @@ -383,7 +457,7 @@ export class EquipmentInteractionRegistry { return nextEffect; } - applyInventoryControlHeatEffects(equipment: MountedEquipment, effect: InventoryControlHeatEffect, context: HandlerContext): InventoryControlHeatEffect { + applyInventoryControlHeatEffects(equipment: MountedEquipment, effect: InventoryControlHeatEffect, context: HandlerQueryContext): InventoryControlHeatEffect { let nextEffect = effect; for (const handler of this.getHandlers(equipment)) { nextEffect = handler.applyInventoryControlHeatEffects?.(equipment, nextEffect, context) ?? nextEffect; @@ -396,7 +470,7 @@ export class EquipmentInteractionRegistry { return nextEffect; } - getInventoryControlHeatEffect(equipment: MountedEquipment, context: HandlerContext): InventoryControlHeatEffect | null { + getInventoryControlHeatEffect(equipment: MountedEquipment, context: HandlerQueryContext): InventoryControlHeatEffect | null { for (const handler of this.getHandlers(equipment)) { const effect = handler.getInventoryControlHeatEffect?.(equipment, context); if (effect) return effect; @@ -404,7 +478,7 @@ export class EquipmentInteractionRegistry { return null; } - matchesInventoryAmmo(equipment: MountedEquipment, ammo: AmmoEquipment, mode: string | null, context: HandlerContext): boolean | null { + matchesInventoryAmmo(equipment: MountedEquipment, ammo: AmmoEquipment, mode: string | null, context: HandlerQueryContext): boolean | null { for (const handler of this.getHandlers(equipment)) { const result = handler.matchesInventoryAmmo?.(equipment, ammo, mode, context); if (result !== undefined && result !== null) return result; @@ -414,7 +488,7 @@ export class EquipmentInteractionRegistry { getToHitAdjustments( equipment: MountedEquipment, - context: HandlerContext, + context: HandlerQueryContext, selectedAmmo?: AmmoEquipment | null ): ToHitAdjustment[] { const adjustments = this.getHandlers(equipment) @@ -427,17 +501,17 @@ export class EquipmentInteractionRegistry { return adjustments; } - canPerformAimedShot(equipment: MountedEquipment, context: HandlerContext): boolean { + canPerformAimedShot(equipment: MountedEquipment, context: HandlerQueryContext): boolean { return this.getHandlers(equipment) .every(handler => handler.canPerformAimedShot?.(equipment, context) !== false); } - isInventoryControlSelectable(equipment: MountedEquipment, context: HandlerContext): boolean { + isInventoryControlSelectable(equipment: MountedEquipment, context: HandlerQueryContext): boolean { return this.getHandlers(equipment) .every(handler => handler.isInventoryControlSelectable?.(equipment, context) !== false); } - inventoryControlRules(context: HandlerContext): InventoryControlRules { + inventoryControlRules(context: HandlerQueryContext): InventoryControlRules { return { applyDisplayEffects: (equipment, display, options) => this.applyInventoryControlDisplayEffects(equipment, display, options, context), applyDamageEffects: (equipment, damage, options) => this.applyInventoryControlDamageEffects(equipment, damage, options, context), @@ -451,14 +525,22 @@ export class EquipmentInteractionRegistry { }; } - getInventoryHeatSources(inventory: readonly MountedEquipment[], turnState: TurnState): UnitHeatSource[] { + getInventoryHeatSources( + inventory: readonly MountedEquipment[], + turnState: TurnState, + context: HandlerQueryContext + ): UnitHeatSource[] { return inventory.flatMap(equipment => this.getHandlers(equipment) - .flatMap(handler => handler.getInventoryHeatSources?.(equipment, turnState) ?? [])); + .flatMap(handler => handler.getInventoryHeatSources?.(equipment, turnState, context) ?? [])); } - getRunMovementMultiplierBonus(inventory: readonly MountedEquipment[], turnState: TurnState): number { + getRunMovementMultiplierBonus( + inventory: readonly MountedEquipment[], + turnState: TurnState, + context: HandlerQueryContext + ): number { return inventory.reduce((total, equipment) => total + this.getHandlers(equipment) - .reduce((equipmentTotal, handler) => equipmentTotal + (handler.getRunMovementMultiplierBonus?.(equipment, turnState) ?? 0), 0), 0); + .reduce((equipmentTotal, handler) => equipmentTotal + (handler.getRunMovementMultiplierBonus?.(equipment, turnState, context) ?? 0), 0), 0); } } @@ -482,4 +564,4 @@ export class EquipmentInteractionRegistryService { getRegistry(): EquipmentInteractionRegistry { return this.registry; } -} \ No newline at end of file +} diff --git a/src/app/services/force-builder.service.ts b/src/app/services/force-builder.service.ts index f8edf75cd..891e4cf67 100644 --- a/src/app/services/force-builder.service.ts +++ b/src/app/services/force-builder.service.ts @@ -1746,11 +1746,7 @@ export class ForceBuilderService { for (const { force } of this.loadedForces()) { force.c3Network(); untracked(() => { - this.equipmentRegistryService.getRegistry().onForceRuntimeChanged(force, { - toastService: this.toastService, - dialogsService: this.dialogsService, - dataService: this.dataService, - }); + this.equipmentRegistryService.getRegistry().onForceRuntimeChanged(force, this.toastService); }); } }); @@ -3353,4 +3349,4 @@ export class ForceBuilderService { dialogRef.close(); } -} \ No newline at end of file +} diff --git a/src/app/services/unit-svg-infantry.service.ts b/src/app/services/unit-svg-infantry.service.ts index 971ce2a2b..61c6c8ee2 100644 --- a/src/app/services/unit-svg-infantry.service.ts +++ b/src/app/services/unit-svg-infantry.service.ts @@ -90,13 +90,11 @@ export class UnitSvgInfantryService extends UnitSvgService { protected override updateInventory() { const svg = this.unit.svg(); if (!svg) return; - // Delegate state computation to the rules layer (handles pending damage too) - this.infantryRules.evaluateInventoryDestruction(); super.updateInventory(); this.updateFieldGunDisplay(); this.unit.getInventory().forEach(entry => { if (!entry.el?.getAttribute('SSW')) return; - if (entry.isDestroyed()) { + if (this.unit.getEquipmentStatus(entry) === 'destroyed') { entry.el.classList.add('damagedInventory'); entry.el.classList.remove('interactive'); entry.el.classList.remove('selected'); diff --git a/src/app/services/unit-svg-mek.service.ts b/src/app/services/unit-svg-mek.service.ts index 10b3baafb..29e0ef051 100644 --- a/src/app/services/unit-svg-mek.service.ts +++ b/src/app/services/unit-svg-mek.service.ts @@ -10,14 +10,12 @@ import { AmmoEquipment } from "../models/equipment.model"; import { MekRules } from "../models/rules/mek-rules"; import type { InventoryControlRuntimeRangeKey } from "../models/inventory-control-runtime-state.model"; import { getCriticalSlotAmmoProfileKey } from "../utils/ammo-interaction.util"; -import type { MountedEquipmentToHit } from "../models/rules/unit-type-rules"; import { INVENTORY_CONTROL_PHYSICAL_BASE_DAMAGE_TEXT_ATTRIBUTE, readInventoryControlDisplayData } from "../utils/inventory-control.util"; export class UnitSvgMekService extends UnitSvgService { // Mek-specific SVG handling logic goes here private get mekRules(): MekRules { return this.unit.rules as MekRules; } - private currentEquipmentToHits: Map | null = null; protected override updateAllDisplays() { if (!this.unit.svg()) return; @@ -86,7 +84,7 @@ export class UnitSvgMekService extends UnitSvgService { } const key = getCriticalSlotAmmoProfileKey(criticalSlot) ?? (text.startsWith("Ammo ") ? text.substring(5) : text); - ammoProfile.set(key, (ammoProfile.get(key) ?? 0) + (this.unit.isEquipmentUnavailable(criticalSlot) ? 0 : remainingAmmo)); + ammoProfile.set(key, (ammoProfile.get(key) ?? 0) + (this.unit.isEquipmentOperational(criticalSlot) ? remainingAmmo : 0)); } } @@ -179,15 +177,9 @@ export class UnitSvgMekService extends UnitSvgService { } // Inventory entries — state from rules, rendering here - const equipmentToHits = this.mekRules.getEquipmentToHits(); - this.currentEquipmentToHits = equipmentToHits; - try { - this.unit.getInventory().forEach(entry => { + this.unit.getInventory().forEach(entry => { if (!entry.el || !entry.locations) return; - const toHit = equipmentToHits.get(entry); - if (!toHit) return; - // Physical / melee damage display (reads base values from DOM, computes via rules) if (entry.isIntrinsicPhysicalAttack()) { switch (entry.name) { @@ -209,28 +201,24 @@ export class UnitSvgMekService extends UnitSvgService { this.renderMeleeDamage(entry, 'physWeapon', undefined, !!entry.equipment?.flags.has('S_FLAIL')); } - const actionUnavailable = entry.isActionUnavailable(); + const actionUnavailable = !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire'); entry.el.classList.toggle('disabledInventory', actionUnavailable); - const destroyed = this.mekRules.getEquipmentStatus(entry) === 'destroyed'; + const destroyed = this.unit.getEquipmentStatus(entry) === 'destroyed'; entry.el.classList.toggle('damagedInventory', destroyed); if (destroyed || actionUnavailable) entry.el.classList.remove('selected'); // Hit modifier badge this.renderHitModEntry(entry, this.resolveInventoryControlToHit(entry)); - }); - this.renderInventoryControlSelection(); - } finally { - this.currentEquipmentToHits = null; - } + }); + this.renderInventoryControlSelection(); } protected override resolveInventoryControlToHit(entry: MountedEquipment, range?: InventoryControlRuntimeRangeKey | null) { - const toHit = this.currentEquipmentToHits?.get(entry) ?? this.mekRules.getEquipmentToHit(entry); + const stateModifiers = this.mekRules.getEquipmentToHitModifiers(entry); const selectedAmmo = this.inventoryTargetSelectedAmmo(entry); return this.unit.gameRules.resolveToHit({ subject: entry, - stateModifier: toHit.modifier, - stateModifierBreakdown: toHit.modifiers, + stateModifiers, range, adjustments: this.unit.getInventoryControlRules().resolveToHitAdjustments?.(entry, selectedAmmo) }); @@ -308,7 +296,7 @@ export class UnitSvgMekService extends UnitSvgService { const { weakened } = this.mekRules.resolveMeleeDamageDisplay(entry, baseDamage, attackType, loc, ignoreMyomer); const display = this.unit.applyInventoryControlDisplayEffects(entry, readInventoryControlDisplayData(entry), { selectedRange: null, - additionalHitModifier: 0, + hitModifierBreakdown: this.mekRules.getEquipmentToHitModifiers(entry), selectedAmmo: null, }); this.renderInventoryDamageText(damageEl, display.damage); @@ -428,4 +416,4 @@ export class UnitSvgMekService extends UnitSvgService { } -} \ No newline at end of file +} diff --git a/src/app/services/unit-svg-vehicle.service.ts b/src/app/services/unit-svg-vehicle.service.ts index 3057d9afc..8445aedc0 100644 --- a/src/app/services/unit-svg-vehicle.service.ts +++ b/src/app/services/unit-svg-vehicle.service.ts @@ -5,7 +5,6 @@ import type { MountedEquipment } from "../models/mounted-equipment.model"; import type { CriticalSlot } from "../models/force-serialization"; import { VehicleRules } from "../models/rules/vehicle-rules"; -import type { MountedEquipmentToHit } from "../models/rules/unit-type-rules"; import type { InventoryControlRuntimeRangeKey } from "../models/inventory-control-runtime-state.model"; import { committedCriticalHitCount, isRepeatableMotiveHitId, MOTIVE_HIT_PIP_COUNT } from "../models/rules/vehicle-motive-hit.util"; import { UnitSvgService } from "./unit-svg.service"; @@ -15,7 +14,6 @@ const VTOL_ROTOR_CRIT_ID = 'rotor'; export class UnitSvgVehicleService extends UnitSvgService { private get vehicleRules(): VehicleRules { return this.unit.rules as VehicleRules; } - private currentEquipmentToHits: Map | null = null; protected override updateAllDisplays() { if (!this.unit.svg()) return; @@ -127,40 +125,30 @@ export class UnitSvgVehicleService extends UnitSvgService { } } - const equipmentToHits = this.vehicleRules.getEquipmentToHits(); - this.currentEquipmentToHits = equipmentToHits; - try { - this.unit.getInventory().forEach(entry => { + this.unit.getInventory().forEach(entry => { if (!entry.el) return; if (entry.isIntrinsicPhysicalAttack()) { if (entry.name === 'charge') { this.renderChargeDamage(entry, this.vehicleRules.chargeDamage()); } } - const toHit = equipmentToHits.get(entry); - if (!toHit) return; - - const actionUnavailable = entry.isActionUnavailable(); + const actionUnavailable = !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire'); entry.el.classList.toggle('disabledInventory', actionUnavailable); - const destroyed = this.vehicleRules.getEquipmentStatus(entry) === 'destroyed'; + const destroyed = this.unit.getEquipmentStatus(entry) === 'destroyed'; entry.el.classList.toggle('damagedInventory', destroyed); if (destroyed || actionUnavailable) entry.el.classList.remove('selected'); this.renderHitModEntry(entry, this.resolveInventoryControlToHit(entry)); - }); - this.renderInventoryControlSelection(); - } finally { - this.currentEquipmentToHits = null; - } + }); + this.renderInventoryControlSelection(); } protected override resolveInventoryControlToHit(entry: MountedEquipment, range?: InventoryControlRuntimeRangeKey | null) { - const toHit = this.currentEquipmentToHits?.get(entry) ?? this.vehicleRules.getEquipmentToHit(entry); + const stateModifiers = this.vehicleRules.getEquipmentToHitModifiers(entry); const selectedAmmo = this.inventoryTargetSelectedAmmo(entry); return this.unit.gameRules.resolveToHit({ subject: entry, - stateModifier: toHit.modifier, - stateModifierBreakdown: toHit.modifiers, + stateModifiers, range, adjustments: this.unit.getInventoryControlRules().resolveToHitAdjustments?.(entry, selectedAmmo) }); diff --git a/src/app/services/unit-svg.service.ts b/src/app/services/unit-svg.service.ts index 636bf30a1..237d9ec42 100644 --- a/src/app/services/unit-svg.service.ts +++ b/src/app/services/unit-svg.service.ts @@ -17,11 +17,11 @@ import { formatGunneryDisplay, formatPilotingDisplay, UNIT_CONDITION_DEFINITIONS import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; import { formatAmmoName } from '../utils/ammo-interaction.util'; import { inventoryTargetCategory, inventoryTargetNumberText, inventoryTargetRangeSelection } from '../utils/inventory-target-number.util'; -import { getInventoryControlGroups, getInventoryControlModes, getSelectedInventoryControlMode, INVENTORY_CONTROL_ORIGINAL_DAMAGE_TEXT_ATTRIBUTE, INVENTORY_CONTROL_PHYSICAL_BASE_DAMAGE_TEXT_ATTRIBUTE, readInventoryControlDisplayData, type InventoryControlAmmoOption, type InventoryControlRow } from '../utils/inventory-control.util'; +import { getInventoryControlGroups, getInventoryControlModes, getSelectedInventoryControlMode, INVENTORY_CONTROL_ORIGINAL_DAMAGE_TEXT_ATTRIBUTE, INVENTORY_CONTROL_PHYSICAL_BASE_DAMAGE_TEXT_ATTRIBUTE, readInventoryControlDisplayData, syncSvgMode, type InventoryControlAmmoOption, type InventoryControlRow } from '../utils/inventory-control.util'; import { inventoryControlDamageRange, resolveInventoryControlDamageText } from '../utils/inventory-control-damage.util'; import { formatInventoryControlHeat, resolveHeatSummarySources, resolveInventoryControlHeatEffect, resolveSelectedWeaponPreviewHeatSources } from '../utils/inventory-control-heat.util'; import { calculateHeatProjection, type HeatProjection } from '../models/turn-state.model'; -import { separateHeatFireModifier, type ToHitResolution } from '../models/rules/game-rules'; +import type { ToHitResolution } from '../models/rules/game-rules'; import type { InventoryControlRuntimeEntryState, InventoryControlRuntimeRangeKey, InventoryControlRuntimeTarget } from '../models/inventory-control-runtime-state.model'; import { isRiscLaserPulseModule, RISC_LASER_PULSE_MODE, selectedRiscLaserMode } from '../equipment-handlers/risc-laser-pulse-module.handler'; @@ -1117,7 +1117,7 @@ export class UnitSvgService { const totalAmmo = entry.totalAmmo ?? this.getInventoryOriginalTotalAmmo(entry); const remainingAmmo = totalAmmo - (entry.consumed ?? 0); const key = `(${formatAmmoName(currentAmmo)})`; - ammoProfile.set(key, (ammoProfile.get(key) ?? 0) + (this.unit.isEquipmentUnavailable(entry) ? 0 : remainingAmmo)); + ammoProfile.set(key, (ammoProfile.get(key) ?? 0) + (this.unit.isEquipmentOperational(entry) ? remainingAmmo : 0)); }); this.renderAmmoProfile(ammoProfile); @@ -1252,12 +1252,11 @@ export class UnitSvgService { } protected resolveInventoryControlToHit(entry: MountedEquipment, range?: InventoryControlRuntimeRangeKey | null): ToHitResolution { - const toHit = this.unit.rules.getEquipmentToHit(entry); + const stateModifiers = this.unit.rules.getEquipmentToHitModifiers(entry); const selectedAmmo = this.inventoryTargetSelectedAmmo(entry); return this.unit.gameRules.resolveToHit({ subject: entry, - stateModifier: toHit.modifier, - stateModifierBreakdown: toHit.modifiers, + stateModifiers, range, adjustments: this.unit.getInventoryControlRules().resolveToHitAdjustments?.(entry, selectedAmmo) }); @@ -1269,7 +1268,6 @@ export class UnitSvgService { if (!row) return null; const hitModifierRange = this.inventoryControlRangeForTarget(entry, target, false); const hitResolution = this.resolveInventoryControlToHit(entry, hitModifierRange); - const { hitModifier, heatFireModifier } = separateHeatFireModifier(hitResolution); const c3Resolution = this.unit.resolveC3Targeting(target); const text = inventoryTargetNumberText({ entry, @@ -1283,8 +1281,7 @@ export class UnitSvgService { pilotingSkill: this.unit.rules.getBasePilotingSkill(), missingMovementModifier, attackModifierBreakdown: this.unit.turnState().getAttackModifierBreakdown(), - hitModifier, - heatFireModifier, + hitResolution, c3DegradationSource: c3Resolution.degradationSource, gameRules: this.unit.gameRules }); @@ -1336,7 +1333,7 @@ export class UnitSvgService { this.renderInventoryControlSelectionColor(entry, target); this.renderInventoryControlHeatEntry(entry, weaponRuleRange); this.renderInventoryControlRangeDamageEntry(entry, weaponRuleRange); - if (!entry.isDestroyed()) { + if (this.unit.getEquipmentStatus(entry) !== 'destroyed') { this.renderHitModEntry(entry, this.resolveInventoryControlToHit(entry, weaponRuleRange)); } entry.el.classList.toggle('selected', selected); @@ -1404,7 +1401,7 @@ export class UnitSvgService { } else { const display = this.unit.applyInventoryControlDisplayEffects(entry, readInventoryControlDisplayData(entry), { selectedRange, - additionalHitModifier: 0, + hitModifierBreakdown: this.unit.rules.getEquipmentToHitModifiers(entry), selectedAmmo: null, }); text.textContent = display.heat; @@ -1584,7 +1581,7 @@ export class UnitSvgService { const hitModText = entry.el.querySelector(`:scope > .hitMod-text`); if (!hitModRect || !hitModText) return; - if (hitModifier === null || entry.isDestroyed()) { + if (hitModifier === null || this.unit.getEquipmentStatus(entry) === 'destroyed') { hitModRect.setAttribute('display', 'none'); hitModText.setAttribute('display', 'none'); entry.el.classList.remove('weakenedHitMod'); @@ -1614,8 +1611,13 @@ export class UnitSvgService { if (!svg) return; this.unit.getInventory().forEach(entry => { if (!entry.el) return; - const status = this.unit.rules.getEquipmentStatus(entry); - const actionUnavailable = entry.isActionUnavailable(); + const status = this.unit.getEquipmentStatus(entry); + const actionUnavailable = !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire'); + syncSvgMode( + entry, + getSelectedInventoryControlMode(entry, this.unit.getEquipmentRegistry(), this.unit.getInventoryControlRules().matchesAmmo), + actionUnavailable, + ); if (entry.isIntrinsicPhysicalAttack()) { if (entry.name === 'charge') { this.renderChargeDamage(entry, this.unit.rules.chargeDamage()); @@ -1983,4 +1985,4 @@ function inventoryControlDirectText(el: SVGElement | undefined, selector: string return direct.tagName.toLocaleLowerCase() === 'text' ? direct : direct.querySelector(':scope > text'); -} \ No newline at end of file +} diff --git a/src/app/testing/unit-test-helpers.spec.ts b/src/app/testing/unit-test-helpers.spec.ts index 93d288265..cd48d71f1 100644 --- a/src/app/testing/unit-test-helpers.spec.ts +++ b/src/app/testing/unit-test-helpers.spec.ts @@ -2,8 +2,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake +import type { EquipmentAction } from '../models/cbt-force-unit.model'; import { WeaponEquipment } from '../models/equipment.model'; -import { MountedEquipment } from '../models/mounted-equipment.model'; +import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import { type CriticalSlot } from '../models/force-serialization'; import { CORE_2026_GAME_RULES } from '../models/rules/game-rules'; import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../models/rules/unit-type-rules'; @@ -54,6 +55,57 @@ describe('CBTForceUnitTestHarness', () => { expect(harness.unit.isInventoryControlEntrySelected(mounted.id)).toBeTrue(); }); + it('resolves mounted and direct critical status from the current slot identity', () => { + const harness = createCBTForceUnitTestHarness(); + const equipment = new WeaponEquipment({ id: 'TestLaser', name: 'Test Laser', type: 'weapon' }); + const snapshot: CriticalSlot = { id: 'TestLaser@RA#0', loc: 'RA', slot: 0, eq: equipment }; + const mounted = harness.addComponent({ + id: 'laser', + name: equipment.name, + equipment, + critSlots: [snapshot], + }); + + harness.addCriticalSlot({ ...snapshot, destroyed: 1 }); + + expect(harness.unit.getEquipmentStatus(snapshot)).toBe('destroyed'); + expect(harness.unit.getEquipmentStatus(mounted)).toBe('destroyed'); + + harness.addCriticalSlot({ ...snapshot, destroyed: undefined }); + + expect(harness.unit.getEquipmentStatus(snapshot)).toBe('available'); + expect(harness.unit.getEquipmentStatus(mounted)).toBe('available'); + }); + + it('uses the Mek two-critical destruction threshold for autocannons', () => { + const harness = createCBTForceUnitTestHarness(); + const autocannon = new WeaponEquipment({ + id: 'ISAC5', + name: 'AC/5', + type: 'weapon', + flags: ['F_AC'], + }); + const first: CriticalSlot = { id: 'ISAC5@RA#0', loc: 'RA', slot: 0, eq: autocannon }; + const second: CriticalSlot = { id: 'ISAC5@RA#1', loc: 'RA', slot: 1, eq: autocannon }; + const mounted = harness.addComponent({ + id: 'ac5', + name: autocannon.name, + equipment: autocannon, + critSlots: [first, second], + }); + + harness.addCriticalSlot({ ...first, destroyed: 1 }); + harness.addCriticalSlot(second); + + expect(harness.unit.getEquipmentStatus(mounted)).toBe('available'); + expect(harness.unit.getEquipmentStatusAtLocation(mounted, 'RA')).toBe('available'); + + harness.addCriticalSlot({ ...second, destroyed: 2 }); + + expect(harness.unit.getEquipmentStatus(mounted)).toBe('destroyed'); + expect(harness.unit.getEquipmentStatusAtLocation(mounted, 'RA')).toBe('destroyed'); + }); + it('provides production-default game rules and equipment disabled state', () => { const harness = createCBTForceUnitTestHarness(); const mounted = harness.addComponent({ @@ -63,7 +115,130 @@ describe('CBTForceUnitTestHarness', () => { }); expect(harness.unit.gameRules).toBe(CORE_2026_GAME_RULES); - expect(harness.unit.rules.getEquipmentStatus(mounted)).toBe('disabled'); + expect(harness.unit.getEquipmentStatus(mounted)).toBe('disabled'); + }); + + it('configures equipment status and to-hit modifiers independently', () => { + const harness = createCBTForceUnitTestHarness(); + const disabled = harness.addComponent({ id: 'disabled-laser', name: 'Disabled Laser' }); + const modified = harness.addComponent({ id: 'modified-laser', name: 'Modified Laser' }); + const modifiers = [{ label: 'Damaged Fire Control', modifier: 2, weakened: true }]; + + harness + .setEquipmentStatus(disabled, 'disabled') + .setEquipmentToHitModifiers(modified, modifiers); + + expect(harness.unit.getEquipmentStatus(disabled)).toBe('disabled'); + expect(harness.unit.rules.getEquipmentToHitModifiers(disabled)).toEqual([]); + expect(harness.unit.getEquipmentStatus(modified)).toBe('available'); + expect(harness.unit.rules.getEquipmentToHitModifiers(modified)).toBe(modifiers); + }); + + it('keeps whole-source and location-scoped status resolvers distinct', () => { + const harness = createCBTForceUnitTestHarness({ + resolveEquipmentStatus: () => 'disabled', + resolveEquipmentStatusAtLocation: (_entry, location) => location === 'RA' ? 'destroyed' : 'available', + }); + const mounted = harness.addComponent({ id: 'laser', name: 'Laser' }); + + expect(harness.unit.getEquipmentStatus(mounted)).toBe('disabled'); + expect(harness.unit.isEquipmentOperational(mounted)).toBeFalse(); + expect(harness.unit.getEquipmentStatusAtLocation(mounted, 'RA')).toBe('destroyed'); + expect(harness.unit.isEquipmentOperationalAtLocation(mounted, 'RA')).toBeFalse(); + expect(harness.unit.canPerformEquipmentAction(mounted, 'fire')).toBeFalse(); + }); + + it('routes equipment actions through an action-aware permission resolver', () => { + const resolveEquipmentActionPermission = jasmine.createSpy('resolveEquipmentActionPermission') + .and.callFake((_entry: MountedEquipment, action: EquipmentAction) => action === 'activate'); + const harness = createCBTForceUnitTestHarness({ resolveEquipmentActionPermission }); + const mounted = harness.addComponent({ id: 'active-probe', name: 'Active Probe' }); + + expect(harness.unit.canPerformEquipmentAction(mounted, 'activate')).toBeTrue(); + expect(harness.unit.canPerformEquipmentAction(mounted, 'fire')).toBeFalse(); + expect(harness.unit.canPerformEquipmentAction(mounted, 'change-mode')).toBeFalse(); + expect(resolveEquipmentActionPermission.calls.allArgs().map(([, action]) => action)).toEqual([ + 'activate', + 'fire', + 'change-mode', + ]); + + const defaultHarness = createCBTForceUnitTestHarness(); + const defaultMounted = defaultHarness.addComponent({ id: 'default-probe', name: 'Default Probe' }); + expect(defaultHarness.unit.canPerformEquipmentAction(defaultMounted, 'activate')).toBeTrue(); + expect(defaultHarness.unit.canPerformEquipmentAction(defaultMounted, 'configure-network')).toBeFalse(); + }); + + it('resolves lifecycle state through canonical unit helpers', () => { + const harness = createCBTForceUnitTestHarness({ + resolveEquipmentStatus: () => 'destroyed', + }); + const mounted = harness.addComponent({ id: 'laser', name: 'Laser' }); + + expect(harness.unit.isEquipmentResolvedDestroyed(mounted)).toBeTrue(); + expect(harness.unit.isEquipmentResolvedCommittedDestroyed(mounted)).toBeTrue(); + + mounted.setCommittedDestroyed(true); + mounted.setPendingDestroyed(false); + + expect(harness.unit.isEquipmentResolvedDestroyed(mounted)).toBeFalse(); + expect(harness.unit.isEquipmentResolvedCommittedDestroyed(mounted)).toBeFalse(); + }); + + it('keeps installation-location loss separate from a repairing mount', () => { + const harness = createCBTForceUnitTestHarness(); + const mounted = harness.addComponent({ + id: 'laser', + name: 'Laser', + locations: new Set(['RA']), + destroyed: true, + }); + harness.setEquipmentStatusAtLocation(mounted, 'RA', 'destroyed'); + mounted.setCommittedDestroyed(true); + mounted.setPendingDestroyed(false); + + expect(harness.unit.getEquipmentInstallationLocationStatus(mounted)).toBe('destroyed'); + expect(harness.unit.isEquipmentResolvedDestroyed(mounted)).toBeTrue(); + expect(harness.unit.isEquipmentResolvedCommittedDestroyed(mounted)).toBeTrue(); + expect(harness.unit.canEditEquipmentState(mounted, 'repair')).toBeFalse(); + }); + + it('defaults installation-location status to available', () => { + const harness = createCBTForceUnitTestHarness(); + const mounted = harness.addComponent({ id: 'laser', name: 'Laser', destroyed: true }); + + expect(harness.unit.getEquipmentInstallationLocationStatus(mounted)).toBe('available'); + expect(harness.unit.canEditEquipmentState(mounted, 'repair')).toBeTrue(); + }); + + it('exposes status/profile-aware effective weapon types through the unit facade', () => { + const harness = createCBTForceUnitTestHarness(); + const weapon = new WeaponEquipment({ id: 'TestLaser', name: 'Test Laser', type: 'weapon' }); + const mounted = harness.addComponent({ id: 'laser', name: weapon.name, equipment: weapon }) as MountedWeapon; + harness.setInventoryControlRules({ + applyWeaponTypes: (_entry, types) => new Set([...types, 'X' as const]), + }); + + expect(harness.unit.getEffectiveWeaponTypes(mounted).has('X')).toBeTrue(); + }); + + it('exposes equipment-aware physical damage through the unit facade', () => { + const harness = createCBTForceUnitTestHarness(); + const mounted = harness.addComponent({ id: 'claw', name: 'Claw' }); + harness.setInventoryControlRules({ + applyPhysicalDamageEffects: (_entry, effect) => ({ + ...effect, + baseDamage: effect.baseDamage + 2, + }), + }); + + expect(harness.unit.getEffectivePhysicalDamageEffect(mounted, { + baseDamage: 5, + ignoreMyomer: false, + })).toEqual({ + baseDamage: 7, + ignoreMyomer: false, + }); }); it('reports no active conditions by default', () => { @@ -80,4 +255,4 @@ describe('CBTForceUnitTestHarness', () => { expect(harness.unit.getCondition('shutdown')).toBeFalse(); expect(harness.unit.getConditions().has('jammed')).toBeTrue(); }); -}); \ No newline at end of file +}); diff --git a/src/app/testing/unit-test-helpers.ts b/src/app/testing/unit-test-helpers.ts index 4390cec09..5478131e3 100644 --- a/src/app/testing/unit-test-helpers.ts +++ b/src/app/testing/unit-test-helpers.ts @@ -5,17 +5,24 @@ import type { Unit } from '../models/units.model'; import { getUnitTechBaseDisplay } from '../models/tech.model'; import { CBTInventoryControlRuntime } from '../models/cbt-inventory-control-runtime.model'; -import type { CBTForceUnit } from '../models/cbt-force-unit.model'; +import type { CBTForceUnit, EquipmentAction, EquipmentStateEdit, EquipmentStatusSource } from '../models/cbt-force-unit.model'; import type { AmmoEquipment, Equipment, EquipmentMap } from '../models/equipment.model'; import { EquipmentRegistry } from '../models/equipment-lookup'; import type { InventoryControlRuntimeRangeKey, InventoryControlRuntimeTarget, InventoryControlRuntimeTargetId } from '../models/inventory-control-runtime-state.model'; -import { type MountedEquipmentInit, MountedEquipment } from '../models/mounted-equipment.model'; +import { type MountedEquipmentInit, MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import { type CriticalSlot, type HeatProfile } from '../models/force-serialization'; import { getMotiveModeLabel, type MotiveModes } from '../models/motiveModes.model'; import { ATTACK_MOVEMENT_MODIFIER_BREAKDOWN_PRIORITY, CORE_2026_GAME_RULES, type CBTGameRules, type C3DegradationSource, type ToHitAdjustment, type ToHitModifierBreakdownEntry } from '../models/rules/game-rules'; -import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE, type MountedEquipmentStatus, type MountedEquipmentToHit, type UnitModifierBreakdownEntry } from '../models/rules/unit-type-rules'; +import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE, type UnitModifierBreakdownEntry } from '../models/rules/unit-type-rules'; +import { + combineEquipmentStatuses, + type EquipmentStatus as MountedEquipmentStatus, + type EquipmentStatusFacts, +} from '../models/equipment-status.model'; +import type { WeaponType } from '../models/weapon-types.model'; import { resolveSelectedInventoryWeaponHeat } from '../utils/inventory-control-heat.util'; -import type { InventoryControlDisplayData, InventoryControlRules } from '../utils/inventory-control.util'; +import type { InventoryControlPhysicalDamageEffect } from '../utils/inventory-control-physical-damage.util'; +import { resolveInventoryControlSelectedAmmoType, type InventoryControlDisplayData, type InventoryControlRules } from '../utils/inventory-control.util'; type TestAlphaStrikeOverrides = Partial> & { dmg?: Partial; @@ -25,6 +32,13 @@ export type TestUnitOverrides = Partial> & { as?: TestAlphaStrikeOverrides; }; +type TestInventoryControlRules = InventoryControlRules & { + applyWeaponTypes?: ( + entry: MountedEquipment, + types: ReadonlySet, + ) => ReadonlySet; +}; + function createEmptyAlphaStrikeStats(overrides: TestAlphaStrikeOverrides = {}): Unit['as'] { const base: Unit['as'] = { TP: 'BM', @@ -145,41 +159,6 @@ export function createEmptyUnit(overrides: TestUnitOverrides = {}): Unit { return unit; } -export interface CBTForceUnitTestEntryState { - status: MountedEquipmentStatus; - toHit: MountedEquipmentToHit; -} - -export function createTestEquipmentState( - status: MountedEquipmentStatus = 'available', - modifiers: readonly ToHitModifierBreakdownEntry[] = [], -): CBTForceUnitTestEntryState { - return { - status, - toHit: { - modifier: modifiers.reduce((total, modifier) => total + modifier.modifier, 0), - modifiers, - }, - }; -} - -export interface TestEquipmentRulesOptions { - getEquipmentStatus?: (entry: MountedEquipment) => MountedEquipmentStatus; - getEquipmentToHit?: (entry: MountedEquipment) => MountedEquipmentToHit; -} - -export function createTestEquipmentRules(options: TestEquipmentRulesOptions = {}) { - const getEquipmentStatus = options.getEquipmentStatus - ?? ((entry: MountedEquipment): MountedEquipmentStatus => entry.committedDestroyed() ? 'destroyed' : 'available'); - const getEquipmentToHit = options.getEquipmentToHit - ?? (() => ({ modifier: 0, modifiers: [] })); - return { - getEquipmentStatus, - getEquipmentToHit, - getEquipmentToHits: () => new Map(), - }; -} - export interface CBTForceUnitTestHarnessOptions { id?: string; unit?: TestUnitOverrides; @@ -188,7 +167,9 @@ export interface CBTForceUnitTestHarnessOptions { components?: readonly MountedEquipment[]; criticalSlots?: readonly CriticalSlot[]; equipment?: EquipmentMap; - entryStates?: ReadonlyMap; + equipmentStatuses?: ReadonlyMap; + equipmentStatusesAtLocation?: ReadonlyMap>; + equipmentToHitModifiers?: ReadonlyMap; heat?: Partial; tracksHeat?: boolean; heatDissipation?: number; @@ -204,9 +185,10 @@ export interface CBTForceUnitTestHarnessOptions { allowExtremeRange?: boolean; readOnly?: boolean; hasDirectInventory?: boolean; - getEquipmentStatus?: (entry: MountedEquipment) => MountedEquipmentStatus; - getEquipmentToHit?: (entry: MountedEquipment) => MountedEquipmentToHit; - isEquipmentUnavailable?: (source: MountedEquipment | CriticalSlot, location?: string) => boolean; + resolveEquipmentStatus?: (source: EquipmentStatusSource) => MountedEquipmentStatus; + resolveEquipmentStatusAtLocation?: (entry: MountedEquipment, location: string) => MountedEquipmentStatus; + resolveEquipmentActionPermission?: (entry: MountedEquipment, action: EquipmentAction) => boolean; + getEquipmentToHitModifiers?: (entry: MountedEquipment) => readonly ToHitModifierBreakdownEntry[]; applyInventoryControlDisplayEffects?: (entry: MountedEquipment, display: InventoryControlDisplayData) => InventoryControlDisplayData; } @@ -228,13 +210,15 @@ export class CBTForceUnitTestHarness { readonly criticalSlots: CriticalSlot[] = []; readonly equipment: EquipmentMap; equipmentRegistry: EquipmentRegistry; - readonly entryStates: Map; + readonly equipmentStatuses: Map; + readonly equipmentStatusesAtLocation: Map>; + readonly equipmentToHitModifiers: Map; readonly heat: HeatProfile; readonly turnState: CBTForceUnitTestTurnState; readonly unit: CBTForceUnit; readonly runtime: CBTInventoryControlRuntime; - private inventoryControlRules: InventoryControlRules = {}; + private inventoryControlRules: TestInventoryControlRules = {}; private toHitAdjustments: ( entry: MountedEquipment, selectedAmmo?: AmmoEquipment | null @@ -243,7 +227,14 @@ export class CBTForceUnitTestHarness { constructor(readonly options: CBTForceUnitTestHarnessOptions = {}) { this.equipment = { ...options.equipment }; this.equipmentRegistry = new EquipmentRegistry(this.equipment); - this.entryStates = new Map(options.entryStates); + this.equipmentStatuses = new Map(options.equipmentStatuses); + this.equipmentStatusesAtLocation = new Map( + Array.from( + options.equipmentStatusesAtLocation ?? [], + ([entry, statuses]) => [entry, new Map(statuses)] as const, + ), + ); + this.equipmentToHitModifiers = new Map(options.equipmentToHitModifiers); this.heat = { current: options.heat?.current ?? 2, previous: options.heat?.previous ?? 1, @@ -291,15 +282,86 @@ export class CBTForceUnitTestHarness { } }; - const getEntryState = (entry: MountedEquipment) => this.entryStates.get(entry) ?? defaultEntryState(entry); - const getEquipmentStatus = (entry: MountedEquipment) => options.getEquipmentStatus?.(entry) - ?? getEntryState(entry).status; - const getEquipmentToHit = (entry: MountedEquipment) => options.getEquipmentToHit?.(entry) - ?? getEntryState(entry).toHit; + const findCurrentCriticalSlot = (snapshot: CriticalSlot): CriticalSlot | null => { + const matches = this.criticalSlots.filter(candidate => { + if (snapshot.loc && snapshot.slot !== undefined) { + return candidate.loc === snapshot.loc && candidate.slot === snapshot.slot; + } + return !!snapshot.id && candidate.id === snapshot.id; + }); + if (matches.length > 1) { + throw new Error(`Duplicate critical-slot identity: ${snapshot.loc ?? snapshot.id}:${snapshot.slot ?? ''}`); + } + return matches[0] ?? null; + }; + const currentCriticalSlots = (entry: MountedEquipment): CriticalSlot[] => ( + entry.critSlots?.flatMap(snapshot => findCurrentCriticalSlot(snapshot) ?? []) ?? [] + ); + const mountedCriticalStatusContribution = ( + destroyedCriticalCount: number, + equipmentFlags: EquipmentStatusFacts['equipmentFlags'], + ): MountedEquipmentStatus => { + const threshold = baseUnit.type === 'Mek' && equipmentFlags.has('F_AC') ? 2 : 1; + return destroyedCriticalCount >= threshold ? 'destroyed' : 'available'; + }; + const mountedCriticalStatus = ( + entry: MountedEquipment, + criticalSlots: readonly CriticalSlot[], + ): MountedEquipmentStatus => mountedCriticalStatusContribution( + criticalSlots.filter(slot => !!slot.destroyed).length, + entry.equipment?.flags ?? new Set(), + ); + const resolveEquipmentStatus = (source: EquipmentStatusSource): MountedEquipmentStatus => { + if (options.resolveEquipmentStatus) return options.resolveEquipmentStatus(source); + if (!(source instanceof MountedEquipment)) { + return findCurrentCriticalSlot(source)?.destroyed ? 'destroyed' : 'available'; + } + const entryStatus = this.equipmentStatuses.get(source) ?? defaultEquipmentStatus(source); + const criticalStatus = mountedCriticalStatus(source, currentCriticalSlots(source)); + return combineEquipmentStatuses([ + entryStatus, + criticalStatus, + ...(this.equipmentStatusesAtLocation.get(source)?.values() ?? []), + ]); + }; + const resolveEquipmentStatusAtLocation = ( + entry: MountedEquipment, + location: string, + ): MountedEquipmentStatus => { + if (options.resolveEquipmentStatusAtLocation) { + return options.resolveEquipmentStatusAtLocation(entry, location); + } + const entryStatus = this.equipmentStatuses.get(entry) ?? defaultEquipmentStatus(entry); + const locationStatus = this.equipmentStatusesAtLocation.get(entry)?.get(location) ?? 'available'; + const criticalStatus = mountedCriticalStatus( + entry, + currentCriticalSlots(entry).filter(slot => slot.loc === location), + ); + return combineEquipmentStatuses([entryStatus, locationStatus, criticalStatus]); + }; + const resolveEquipmentInstallationLocationStatus = (entry: MountedEquipment): MountedEquipmentStatus => { + const locations = new Set([ + ...(entry.locations ?? []), + ...currentCriticalSlots(entry).flatMap(slot => slot.loc ? [slot.loc] : []), + ]); + return combineEquipmentStatuses(Array.from( + locations, + location => resolveEquipmentStatusAtLocation(entry, location), + )); + }; + const getEquipmentToHitModifiers = (entry: MountedEquipment) => options.getEquipmentToHitModifiers?.(entry) + ?? this.equipmentToHitModifiers.get(entry) + ?? []; const rules = { - getEquipmentStatus, - getEquipmentToHits: () => new Map(Array.from(this.entryStates, ([entry, state]) => [entry, state.toHit])), - getEquipmentToHit, + getEquipmentStatusContribution: () => 'available' as const, + getMountedCriticalStatusContribution: (facts: EquipmentStatusFacts) => mountedCriticalStatusContribution( + facts.criticals.filter(critical => critical.status === 'destroyed').length, + facts.equipmentFlags, + ), + getEquipmentStatusContributionAtLocation: () => 'available' as const, + getCriticalSlotStatusContribution: () => 'available' as const, + getUnitSystemStatusFacts: () => ({ engineHit: false }), + getEquipmentToHitModifiers, heatDissipation: () => options.tracksHeat === false ? null : { totalPips: 10, healthyPips: 10, @@ -317,6 +379,8 @@ export class CBTForceUnitTestHarness { }, getBaseGunnerySkill: () => options.gunnerySkill ?? 4, getBasePilotingSkill: () => options.pilotingSkill ?? 5, + canPerformEquipmentAction: (entry: MountedEquipment, action: EquipmentAction) => + options.resolveEquipmentActionPermission?.(entry, action) ?? action !== 'configure-network', applyInventoryControlDisplayEffects: (entry: MountedEquipment, display: InventoryControlDisplayData) => options.applyInventoryControlDisplayEffects?.(entry, display) ?? display }; @@ -354,20 +418,85 @@ export class CBTForceUnitTestHarness { }, readOnly: () => options.readOnly ?? false, hasDirectInventory: () => options.hasDirectInventory ?? true, + setInventory: (inventory: MountedEquipment[]) => { + const nextInventory = [...inventory]; + this.components.splice(0, this.components.length); + nextInventory.forEach(entry => this.addComponent(entry)); + this.runtime.markAmmoSourcesChanged(); + }, setInventoryEntry: (entry: MountedEquipment) => { this.addComponent(entry); - this.runtime.markInventoryViewChanged(); + this.runtime.markAmmoSourcesChanged(); + }, + setCritSlot: (slot: CriticalSlot) => { + this.addCriticalSlot(slot); + this.runtime.markAmmoSourcesChanged(); }, - setCritSlot: () => undefined, - isEquipmentUnavailable: options.isEquipmentUnavailable ?? defaultEquipmentUnavailable, - isEquipmentActionUnavailable: (source: MountedEquipment | CriticalSlot) => - conditions.has('shutdown') || (options.isEquipmentUnavailable ?? defaultEquipmentUnavailable)(source), + findCurrentCriticalSlot, + getEquipmentStatus: (source: EquipmentStatusSource) => resolveEquipmentStatus(source), + getEquipmentStatusAtLocation: (entry: MountedEquipment, location: string) => + resolveEquipmentStatusAtLocation(entry, location), + getEquipmentInstallationLocationStatus: resolveEquipmentInstallationLocationStatus, + isEquipmentOperational: (source: EquipmentStatusSource) => resolveEquipmentStatus(source) === 'available', + isEquipmentOperationalAtLocation: (entry: MountedEquipment, location: string) => + resolveEquipmentStatusAtLocation(entry, location) === 'available', + isEquipmentResolvedDestroyed: (entry: MountedEquipment) => + this.unit.getEquipmentInstallationLocationStatus(entry) === 'destroyed' + || (!entry.isRepairing() && (entry.isDestroying() || resolveEquipmentStatus(entry) === 'destroyed')), + isEquipmentResolvedCommittedDestroyed: (entry: MountedEquipment) => + this.unit.getEquipmentInstallationLocationStatus(entry) === 'destroyed' + || (!entry.isRepairing() && resolveEquipmentStatus(entry) === 'destroyed'), + canPerformEquipmentAction: (entry: MountedEquipment, action: EquipmentAction) => { + if (action !== 'configure-network' + && (resolveEquipmentStatus(entry) !== 'available' || conditions.has('shutdown'))) return false; + return rules.canPerformEquipmentAction(entry, action); + }, + canEditEquipmentState: (entry: MountedEquipment, edit: EquipmentStateEdit) => { + if (options.readOnly) return false; + const status = resolveEquipmentStatus(entry); + if (edit === 'enable') return status === 'disabled'; + if (edit === 'disable') return status === 'available'; + if (edit === 'repair') { + return this.unit.getEquipmentInstallationLocationStatus(entry) !== 'destroyed' + && (entry.isDestroying() || (entry.committedDestroyed() && !entry.isRepairing())); + } + return !this.unit.isEquipmentResolvedDestroyed(entry); + }, + applyEquipmentDamage: (entry: MountedEquipment) => { + if (!this.unit.canEditEquipmentState(entry, 'apply-damage')) return false; + if (!entry.setPendingDestroyed(true)) return false; + this.unit.setInventoryEntry(entry); + return true; + }, + repairEquipment: (entry: MountedEquipment) => { + if (!this.unit.canEditEquipmentState(entry, 'repair')) return false; + if (!entry.setPendingDestroyed(false)) return false; + this.unit.setInventoryEntry(entry); + return true; + }, + matchesInventoryControlAmmo: (entry: MountedEquipment, ammo: AmmoEquipment, mode: string | null) => + this.inventoryControlRules.matchesAmmo?.(entry, ammo, mode) ?? null, getInventoryControlRules: () => this.inventoryControlRules, rules } as unknown as CBTForceUnit; this.runtime = installInventoryControlRuntime(this.unit); Object.assign(this.unit, { + getInventoryControlSelectedAmmo: (entry: MountedEquipment, mode?: string | null) => resolveInventoryControlSelectedAmmoType( + entry, + this.equipmentRegistry, + (weapon, ammo, selectedMode) => this.unit.matchesInventoryControlAmmo(weapon, ammo, selectedMode), + this.runtime.getEntryAmmoSelection(entry.id), + mode, + ), + getEffectiveWeaponTypes: (entry: MountedWeapon) => { + const baseTypes = new Set(entry.getWeaponTypes(this.unit.getInventoryControlSelectedAmmo(entry))); + return this.inventoryControlRules.applyWeaponTypes?.(entry, baseTypes) ?? baseTypes; + }, + getEffectivePhysicalDamageEffect: ( + entry: MountedEquipment, + effect: InventoryControlPhysicalDamageEffect, + ) => this.inventoryControlRules.applyPhysicalDamageEffects?.(entry, effect) ?? effect, selectedInventoryWeaponHeat: () => resolveSelectedInventoryWeaponHeat( this.components, this.runtime.entryStates(), @@ -382,7 +511,8 @@ export class CBTForceUnitTestHarness { const mounted = component instanceof MountedEquipment ? component : MountedEquipment.from({ ...component, owner: this.unit }); - mounted.owner = this.unit; + // Test fixtures may be created before their harness; production ownership remains immutable. + (mounted as { owner: CBTForceUnit }).owner = this.unit; const existingIndex = this.components.findIndex(candidate => candidate.id === mounted.id); if (existingIndex === -1) { this.components.push(mounted); @@ -411,12 +541,28 @@ export class CBTForceUnitTestHarness { return equipment; } - setEntryState(entry: MountedEquipment, state: CBTForceUnitTestEntryState): this { - this.entryStates.set(entry, state); + setEquipmentStatus(entry: MountedEquipment, status: MountedEquipmentStatus): this { + this.equipmentStatuses.set(entry, status); return this; } - setInventoryControlRules(rules: InventoryControlRules): this { + setEquipmentStatusAtLocation( + entry: MountedEquipment, + location: string, + status: MountedEquipmentStatus, + ): this { + const statuses = this.equipmentStatusesAtLocation.get(entry) ?? new Map(); + statuses.set(location, status); + this.equipmentStatusesAtLocation.set(entry, statuses); + return this; + } + + setEquipmentToHitModifiers(entry: MountedEquipment, modifiers: readonly ToHitModifierBreakdownEntry[]): this { + this.equipmentToHitModifiers.set(entry, modifiers); + return this; + } + + setInventoryControlRules(rules: TestInventoryControlRules): this { this.inventoryControlRules = rules; return this; } @@ -437,20 +583,12 @@ export function createCBTForceUnitTestHarness(options: CBTForceUnitTestHarnessOp return new CBTForceUnitTestHarness(options); } -function defaultEntryState(entry: MountedEquipment): CBTForceUnitTestEntryState { - const status = entry.committedDestroyed() +function defaultEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus { + return entry.committedDestroyed() ? 'destroyed' : entry.states.get(ENTRY_DISABLED_STATE_KEY) === ENTRY_DISABLED_STATE_VALUE ? 'disabled' : 'available'; - return createTestEquipmentState(status); -} - -function defaultEquipmentUnavailable(source: MountedEquipment | CriticalSlot): boolean { - if (source instanceof MountedEquipment) { - return source.committedDestroyed() || !!source.critSlots?.some(slot => !!slot.destroyed); - } - return !!source.destroyed; } function installInventoryControlRuntime(unit: CBTForceUnit): CBTInventoryControlRuntime { @@ -464,11 +602,11 @@ function installInventoryControlRuntime(unit: CBTForceUnit): CBTInventoryControl getInventoryControlEntryTargetId: (entryId: string) => runtime.getEntryTargetId(entryId), isInventoryControlEntrySelected: (entryId: string) => runtime.isEntrySelected(entryId), getInventoryControlEntryRange: (entryId: string) => runtime.getEntryRange(entryId), - getInventoryControlEntryAmmoOption: (entryId: string) => runtime.getEntryAmmoOption(entryId), + getInventoryControlEntryAmmoSelection: (entryId: string) => runtime.getEntryAmmoSelection(entryId), setInventoryControlEntrySelected: (entry: MountedEquipment, selected: boolean) => runtime.setEntrySelected(entry, selected), setInventoryControlEntryRange: (entry: MountedEquipment, range: InventoryControlRuntimeRangeKey | null) => runtime.setEntryRange(entry, range), toggleInventoryControlEntryRange: (entry: MountedEquipment, range: InventoryControlRuntimeRangeKey, forceSelected = false) => runtime.toggleEntryRange(entry, range, forceSelected), - setInventoryControlEntryAmmoOption: (entryId: string, optionId: string) => runtime.setEntryAmmoOption(entryId, optionId), + setInventoryControlEntryAmmoSelection: (entryId: string, selection: Parameters[1]) => runtime.setEntryAmmoSelection(entryId, selection), setInventoryControlEntryTarget: (entry: MountedEquipment, targetId: InventoryControlRuntimeTargetId | null) => runtime.setEntryTarget(entry, targetId), createInventoryControlTarget: () => runtime.createTarget(), updateInventoryControlTarget: (targetId: InventoryControlRuntimeTargetId, patch: Partial>) => runtime.updateTarget(targetId, patch), @@ -478,4 +616,4 @@ function installInventoryControlRuntime(unit: CBTForceUnit): CBTInventoryControl syncInventoryControlSelectionSvg: () => runtime.syncSelectionSvg() }); return runtime; -} \ No newline at end of file +} diff --git a/src/app/utils/ammo-interaction.util.spec.ts b/src/app/utils/ammo-interaction.util.spec.ts index a4cc1e0b2..23d323708 100644 --- a/src/app/utils/ammo-interaction.util.spec.ts +++ b/src/app/utils/ammo-interaction.util.spec.ts @@ -7,7 +7,11 @@ import { EquipmentRegistry } from '../models/equipment-lookup'; import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import { type CriticalSlot } from '../models/force-serialization'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import { + createHandlerCommandContext, + type HandlerCommandContext, + type HandlerDialogsService, +} from '../services/equipment-interaction-registry.service'; import { changeAmmoEntryRemaining, changeAmmoGroupRemaining, getAmmoControlEntriesForUnitWeapons, getAmmoControlGroups, getAmmoEntryRemaining, getAmmoGroupRemaining, isIntrinsicOneShotAmmoMount, materializeIntrinsicOneShotAmmoForInventory, setAmmoEntryValue, type AmmoControlEntry } from './ammo-interaction.util'; function createAmmo(id: string, shortName: string): AmmoEquipment { @@ -24,7 +28,7 @@ function createEquipmentCatalog(equipment: Record): Equipment return new EquipmentRegistry(equipment); } -function createContext(equipment: Record): HandlerContext { +function createContext(equipment: Record): HandlerCommandContext { const toasts: Array<{ id: string; message: string; type: 'info' | 'success' | 'error'; data?: Record }> = []; const showToast = jasmine.createSpy('showToast').and.callFake((message: string, type: 'info' | 'success' | 'error', id?: string, data?: Record) => { const toastId = id ?? `toast-${toasts.length + 1}`; @@ -36,16 +40,18 @@ function createContext(equipment: Record): HandlerContext { } return toastId; }); - return { - dataService: { - getEquipmentRegistry: () => new EquipmentRegistry(equipment), - }, - toastService: { + const dialogsService = jasmine.createSpyObj( + 'HandlerDialogsService', + ['createDialog', 'showError', 'showNoticeHtml'], + ); + return createHandlerCommandContext( + new EquipmentRegistry(equipment), + { showToast, toasts: () => toasts, }, - dialogsService: {}, - } as unknown as HandlerContext; + dialogsService, + ); } function createEntry(params: { @@ -89,7 +95,7 @@ function createCritEntry(params: { slot: number; ammo: AmmoEquipment; destroyed?: boolean; - owner: Pick; + owner: Pick; }): AmmoControlEntry { const source = { id: params.id, @@ -119,20 +125,27 @@ function createCritEntry(params: { }; } -function testEquipmentUnavailable(source: MountedEquipment | CriticalSlot): boolean { - if (source instanceof MountedEquipment) return source.committedDestroyed() || !!source.critSlots?.some(slot => !!slot.destroyed); - return !!source.destroyed; +function testEquipmentStatus(source: MountedEquipment | CriticalSlot): 'available' | 'destroyed' { + const destroyed = source instanceof MountedEquipment + ? source.committedDestroyed() || !!source.critSlots?.some(slot => !!slot.destroyed) + : !!source.destroyed; + return destroyed ? 'destroyed' : 'available'; +} + +function testEquipmentOperational(source: MountedEquipment | CriticalSlot): boolean { + return testEquipmentStatus(source) === 'available'; } describe('ammo interaction direct inventory groups', () => { const standardAmmo = createAmmo('Clan Ultra AC/20 Ammo', 'Ultra AC/20 Ammo'); const precisionAmmo = createAmmo('Clan Ultra AC/20 Precision Ammo', 'Ultra AC/20 Precision Ammo'); - function createOwner(): Pick { + function createOwner(): Pick { return { id: 'unit-1', setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - isEquipmentUnavailable: testEquipmentUnavailable, + getEquipmentStatus: testEquipmentStatus, + isEquipmentOperational: testEquipmentOperational, getUnit: () => ({ techBase: 'Clan', comp: [ @@ -140,7 +153,7 @@ describe('ammo interaction direct inventory groups', () => { { id: standardAmmo.internalName, q: 2, q2: 10, n: 'Ultra AC/20 Ammo', t: 'X', p: 0, l: 'BD' }, ], }), - } as unknown as Pick; + } as unknown as Pick; } it('groups direct inventory bins by current ammo type and location', () => { @@ -188,7 +201,7 @@ describe('ammo interaction direct inventory groups', () => { ]), }, svg: () => null, - } as unknown as Pick; + } as unknown as Pick; const entries = [ createCritEntry({ id: 'ammo-lt-0', loc: 'LT', slot: 0, ammo: standardAmmo, owner }), createCritEntry({ id: 'ammo-lt-1', loc: 'LT', slot: 1, ammo: standardAmmo, owner }), @@ -212,7 +225,7 @@ describe('ammo interaction direct inventory groups', () => { setCritSlot: jasmine.createSpy('setCritSlot'), getUnit: () => ({ techBase: 'Clan' }), svg: () => null, - } as unknown as Pick; + } as unknown as Pick; const entries = [ createCritEntry({ id: 'ammo-lt-5', loc: 'LT', slot: 5, ammo: standardAmmo, owner }), createCritEntry({ id: 'ammo-lt-1', loc: 'LT', slot: 1, ammo: standardAmmo, owner }), @@ -250,7 +263,8 @@ describe('ammo interaction direct inventory groups', () => { { id: 'Clan Gauss Ammo@RA#1', name: gaussAmmo.internalName, loc: 'RA', slot: 1, eq: gaussAmmo, totalAmmo: 8, consumed: 0 }, ]), svg: () => null, - isEquipmentUnavailable: testEquipmentUnavailable, + getEquipmentStatus: testEquipmentStatus, + isEquipmentOperational: testEquipmentOperational, } as unknown as CBTForceUnit; const entries = getAmmoControlEntriesForUnitWeapons(owner, createEquipmentCatalog({ @@ -270,8 +284,8 @@ describe('ammo interaction direct inventory groups', () => { type: 'weapon', weapon: { ammoType: 'AC_ULTRA', rackSize: 20 } }); - const weaponEntry = { id: 'CLUltraAC20@RA#0', name: weapon.internalName, equipment: weapon, states: new Map() } as unknown as MountedEquipment; - const ammoEntry = { id: `${standardAmmo.internalName}@RA#1`, name: standardAmmo.internalName, equipment: standardAmmo, locations: new Set(['RA']), totalAmmo: 5, consumed: 0, states: new Map() } as unknown as MountedEquipment; + let weaponEntry!: MountedEquipment; + let ammoEntry!: MountedEquipment; const owner = { getInventory: () => ([weaponEntry, ammoEntry]), getCritSlots: () => ([ @@ -279,10 +293,13 @@ describe('ammo interaction direct inventory groups', () => { ]), getUnit: () => ({ comp: [], techBase: 'Clan' }), svg: () => null, - isEquipmentUnavailable: (source: MountedEquipment | CriticalSlot) => source === ammoEntry || (source as CriticalSlot).loc === 'LA', + getEquipmentStatus: (source: MountedEquipment | CriticalSlot) => source === ammoEntry || (source as CriticalSlot).loc === 'LA' + ? 'destroyed' + : 'available', + isEquipmentOperational: (source: MountedEquipment | CriticalSlot) => source !== ammoEntry && (source as CriticalSlot).loc !== 'LA', } as unknown as CBTForceUnit; - weaponEntry.owner = owner; - ammoEntry.owner = owner; + weaponEntry = new MountedEquipment({ owner, id: 'CLUltraAC20@RA#0', name: weapon.internalName, equipment: weapon, states: new Map() }); + ammoEntry = new MountedEquipment({ owner, id: `${standardAmmo.internalName}@RA#1`, name: standardAmmo.internalName, equipment: standardAmmo, locations: new Set(['RA']), totalAmmo: 5, consumed: 0, states: new Map() }); const entries = getAmmoControlEntriesForUnitWeapons(owner, createEquipmentCatalog({ [weapon.internalName]: weapon, @@ -346,8 +363,9 @@ describe('ammo interaction direct inventory groups', () => { setCritSlot: jasmine.createSpy('setCritSlot'), getUnit: () => ({ techBase: 'Clan' }), svg: () => null, - isEquipmentUnavailable: testEquipmentUnavailable, - } as unknown as Pick; + getEquipmentStatus: testEquipmentStatus, + isEquipmentOperational: testEquipmentOperational, + } as unknown as Pick; const context = createContext({ [standardAmmo.internalName]: standardAmmo }); const entries = [ createCritEntry({ id: 'ammo-lt-0', loc: 'LT', slot: 0, ammo: standardAmmo, destroyed: true, owner }), @@ -372,8 +390,9 @@ describe('ammo interaction direct inventory groups', () => { setCritSlot: jasmine.createSpy('setCritSlot'), getUnit: () => ({ techBase: 'Clan' }), svg: () => null, - isEquipmentUnavailable: testEquipmentUnavailable, - } as unknown as Pick; + getEquipmentStatus: testEquipmentStatus, + isEquipmentOperational: testEquipmentOperational, + } as unknown as Pick; const entries = [ createCritEntry({ id: 'ammo-lt-0', loc: 'LT', slot: 0, ammo: standardAmmo, destroyed: true, owner }), createCritEntry({ id: 'ammo-lt-1', loc: 'LT', slot: 1, ammo: standardAmmo, destroyed: true, owner }), @@ -407,7 +426,8 @@ describe('intrinsic one-shot ammo mounts', () => { getUnit: () => ({ techBase: 'IS', type: 'Battle Armor', comp: [] }), getInventory: () => inventory, getCritSlots: () => [], - isEquipmentUnavailable: () => false, + getEquipmentStatus: () => 'available', + isEquipmentOperational: () => true, setInventoryEntry: jasmine.createSpy('setInventoryEntry'), setCritSlot: jasmine.createSpy('setCritSlot'), } as unknown as CBTForceUnit; diff --git a/src/app/utils/ammo-interaction.util.ts b/src/app/utils/ammo-interaction.util.ts index d4b4299b4..f0994b9f4 100644 --- a/src/app/utils/ammo-interaction.util.ts +++ b/src/app/utils/ammo-interaction.util.ts @@ -9,7 +9,7 @@ import type { EquipmentRegistry } from '../models/equipment-lookup'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; import { getMountedOneShotConsumed, MountedAmmo, MountedEquipment } from '../models/mounted-equipment.model'; import { type CriticalSlot, type LocationData } from '../models/force-serialization'; -import type { HandlerContext } from '../services/equipment-interaction-registry.service'; +import type { HandlerCommandContext } from '../services/equipment-interaction-registry.service'; import type { CBTGameRules } from '../models/rules/game-rules'; import type { Unit } from '../models/units.model'; import { normalizeBattleArmorTrooperLocation } from '../models/battle-armor-location.model'; @@ -116,7 +116,7 @@ export function getAmmoControlEntryForCriticalSlot(unit: CBTForceUnit, criticalS originalTotalAmmo: getOriginalTotalAmmo(unit, criticalSlot), totalAmmo, consumed: criticalSlot.consumed ?? 0, - destroyed: unit.isEquipmentUnavailable(criticalSlot) + destroyed: !unit.isEquipmentOperational(criticalSlot) }; } @@ -141,10 +141,10 @@ function createInventoryAmmoControlEntry(unit: CBTForceUnit, inventoryEntry: Mou const totalAmmo = inventoryEntry.totalAmmo ?? originalTotalAmmo; const consumed = inventoryEntry.consumed ?? 0; const locationLabel = Array.from(inventoryEntry.locations ?? []).join('/') || 'Ammo'; - const destroyed = unit.isEquipmentUnavailable(inventoryEntry) + const destroyed = !unit.isEquipmentOperational(inventoryEntry) || (isIntrinsicOneShotAmmoMount(inventoryEntry) && !!inventoryEntry.parent - && unit.isEquipmentUnavailable(inventoryEntry.parent)); + && !unit.isEquipmentOperational(inventoryEntry.parent)); return { id: `inventory:${inventoryEntry.id}`, owner: unit, @@ -347,9 +347,8 @@ function compareAmmoControlEntryOrder(a: AmmoControlEntry, b: AmmoControlEntry): return a.id.localeCompare(b.id); } -export function getAmmoControlEntriesForWeapon(equipment: MountedEquipment, context: HandlerContext): AmmoControlEntry[] { +export function getAmmoControlEntriesForWeapon(equipment: MountedEquipment, equipmentCatalog: EquipmentRegistry): AmmoControlEntry[] { if (!(equipment.equipment instanceof WeaponEquipment)) return []; - const equipmentCatalog = context.dataService.getEquipmentRegistry(); const intrinsicAmmo = getIntrinsicOneShotAmmoMount(equipment); if (equipment.equipment.oneShotCount) { const intrinsicAmmoEntry = intrinsicAmmo @@ -519,10 +518,10 @@ function syncEntryFromSource(entry: AmmoControlEntry, equipmentCatalog: Equipmen entry.originalTotalAmmo = getInventoryOriginalTotalAmmo(source); entry.totalAmmo = source.totalAmmo ?? entry.originalTotalAmmo; entry.consumed = source.consumed ?? 0; - entry.destroyed = entry.owner.isEquipmentUnavailable(source) + entry.destroyed = !entry.owner.isEquipmentOperational(source) || (isIntrinsicOneShotAmmoMount(source) && !!source.parent - && entry.owner.isEquipmentUnavailable(source.parent)); + && !entry.owner.isEquipmentOperational(source.parent)); return; } @@ -535,10 +534,10 @@ function syncEntryFromSource(entry: AmmoControlEntry, equipmentCatalog: Equipmen entry.originalTotalAmmo = getOriginalTotalAmmo(entry.owner, entry.source as CriticalSlot); entry.totalAmmo = getCriticalSlotTotalAmmo(entry.owner, entry.source as CriticalSlot); entry.consumed = (entry.source as CriticalSlot).consumed ?? 0; - entry.destroyed = entry.owner.isEquipmentUnavailable(entry.source as CriticalSlot); + entry.destroyed = !entry.owner.isEquipmentOperational(entry.source as CriticalSlot); } -function showAmmoToast(entry: AmmoControlEntry, deltaRemaining: number, context: HandlerContext): void { +function showAmmoToast(entry: AmmoControlEntry, deltaRemaining: number, context: HandlerCommandContext): void { const toastId = `ammo-control-${entry.owner.id}-${entry.id}`; const existingDelta = readAmmoToastDelta(context, toastId, deltaRemaining); const accumulatedDelta = existingDelta + deltaRemaining; @@ -551,13 +550,13 @@ function showAmmoToast(entry: AmmoControlEntry, deltaRemaining: number, context: ); } -function readAmmoToastDelta(context: HandlerContext, toastId: string, deltaRemaining: number): number { +function readAmmoToastDelta(context: HandlerCommandContext, toastId: string, deltaRemaining: number): number { const existingToast = context.toastService.toasts().find(toast => toast.id === toastId); const delta = existingToast?.data?.['ammoDeltaRemaining']; return typeof delta === 'number' && Math.sign(delta) === Math.sign(deltaRemaining) ? delta : 0; } -export function changeAmmoEntryRemaining(entry: AmmoControlEntry, deltaRemaining: number, context: HandlerContext): boolean { +export function changeAmmoEntryRemaining(entry: AmmoControlEntry, deltaRemaining: number, context: HandlerCommandContext): boolean { if (entry.destroyed) return false; const currentRemaining = getAmmoEntryRemaining(entry); const nextRemaining = clamp(currentRemaining + deltaRemaining, 0, entry.totalAmmo); @@ -565,12 +564,12 @@ export function changeAmmoEntryRemaining(entry: AmmoControlEntry, deltaRemaining if (appliedDelta === 0) return false; setAmmoEntryValue(entry, entry.currentAmmo, entry.totalAmmo, nextRemaining); - syncEntryFromSource(entry, context.dataService.getEquipmentRegistry()); + syncEntryFromSource(entry, context.equipmentCatalog); showAmmoToast(entry, appliedDelta, context); return true; } -export function changeAmmoEntriesRemaining(entries: AmmoControlEntry[], deltaRemaining: number, context: HandlerContext): boolean { +export function changeAmmoEntriesRemaining(entries: AmmoControlEntry[], deltaRemaining: number, context: HandlerCommandContext): boolean { if (deltaRemaining === 0) return false; const sortedEntries = [...entries].sort(compareAmmoControlEntryOrder); const reversedEntries = [...sortedEntries].reverse(); @@ -596,7 +595,7 @@ export function getAmmoGroupRemaining(group: AmmoControlGroup): number { return group.entries.reduce((total, entry) => total + getAmmoEntryRemaining(entry), 0); } -export function changeAmmoGroupRemaining(group: AmmoControlGroup, deltaRemaining: number, context: HandlerContext): boolean { +export function changeAmmoGroupRemaining(group: AmmoControlGroup, deltaRemaining: number, context: HandlerCommandContext): boolean { const changed = changeAmmoEntriesRemaining(group.entries, deltaRemaining, context); if (changed) syncGroupTotals(group); @@ -626,10 +625,10 @@ function getTotalAmmoForAmmoType( return Math.floor((originalAmmo.getEffectiveKgPerShot(gameRules, equipmentRegistry) * originalTotalAmmo) / selectedKgPerShot); } -export async function setAmmoEntry(entry: AmmoControlEntry, context: HandlerContext): Promise { +export async function setAmmoEntry(entry: AmmoControlEntry, context: HandlerCommandContext): Promise { if (entry.destroyed) return false; - const equipmentRegistry = context.dataService.getEquipmentRegistry(); + const equipmentRegistry = context.equipmentCatalog; const unitBlueprint = entry.owner.getUnit(); const inventory = entry.owner.getInventory(); const compatibleAmmo = getCompatibleCatalogAmmo(entry.originalAmmo, equipmentRegistry, unitBlueprint, inventory); @@ -672,12 +671,12 @@ export async function setAmmoEntry(entry: AmmoControlEntry, context: HandlerCont return true; } -export async function setAmmoGroup(group: AmmoControlGroup, context: HandlerContext): Promise { +export async function setAmmoGroup(group: AmmoControlGroup, context: HandlerCommandContext): Promise { if (group.entries.length === 1) return setAmmoEntry(group.entries[0], context); if (group.destroyed) return false; const firstEntry = group.entries[0]; - const equipmentRegistry = context.dataService.getEquipmentRegistry(); + const equipmentRegistry = context.equipmentCatalog; const unitBlueprint = firstEntry.owner.getUnit(); const inventory = firstEntry.owner.getInventory(); const originalTotalAmmo = group.entries.reduce((total, entry) => total + entry.originalTotalAmmo, 0); @@ -733,4 +732,4 @@ export async function setAmmoGroup(group: AmmoControlGroup, context: HandlerCont ); } return true; -} \ No newline at end of file +} diff --git a/src/app/utils/cbtprint.util.ts b/src/app/utils/cbtprint.util.ts index 5f3d50593..f2f5ac270 100644 --- a/src/app/utils/cbtprint.util.ts +++ b/src/app/utils/cbtprint.util.ts @@ -158,7 +158,7 @@ export class CBTPrintUtil { const defaultMode = getSelectedInventoryControlMode( entry, EMPTY_EQUIPMENT_REGISTRY, - printUnit.getInventoryControlRules() + printUnit.getInventoryControlRules().matchesAmmo ); syncSvgMode(entry, defaultMode, false); } @@ -964,4 +964,4 @@ export class CBTPrintUtil { `; } -} \ No newline at end of file +} diff --git a/src/app/utils/inventory-control-ammo.util.spec.ts b/src/app/utils/inventory-control-ammo.util.spec.ts index 774a951a1..ab7785668 100644 --- a/src/app/utils/inventory-control-ammo.util.spec.ts +++ b/src/app/utils/inventory-control-ammo.util.spec.ts @@ -7,7 +7,7 @@ import { EquipmentRegistry } from '../models/equipment-lookup'; import { MountedAmmo, MountedWeapon } from '../models/mounted-equipment.model'; import type { CBTForceUnit } from '../models/cbt-force-unit.model'; import { createEmptyUnit } from '../testing/unit-test-helpers'; -import { getInventoryControlModeAmmoSummary, resolveInventoryControlSelectedAmmoOption, type InventoryControlAmmoOption } from './inventory-control.util'; +import { getInventoryControlAmmoProfileId, getInventoryControlModeAmmoSummary, resolveInventoryControlSelectedAmmoOption, type InventoryControlAmmoOption } from './inventory-control.util'; describe('inventory-control ammo selection', () => { it('uses stable source order when no choice is persisted', () => { @@ -17,6 +17,14 @@ describe('inventory-control ammo selection', () => { expect(resolveInventoryControlSelectedAmmoOption([first, second])).toBe(first); }); + it('uses the stable first profile when another profile is the only one with remaining shots', () => { + const depletedStandard = option('standard:first', 'Standard', 0); + const usablePrecision = option('precision:first', 'Precision', 10); + + expect(resolveInventoryControlSelectedAmmoOption([depletedStandard, usablePrecision])) + .toBe(depletedStandard); + }); + it('fails over to a usable bin of the same munition', () => { const depleted = option('standard:first', 'Standard', 0); const sameMunition = option('standard:second', 'Standard', 2); @@ -24,7 +32,8 @@ describe('inventory-control ammo selection', () => { expect(resolveInventoryControlSelectedAmmoOption( [depleted, otherMunition, sameMunition], - depleted.id + depleted.profileId, + depleted.id, )).toBe(sameMunition); }); @@ -32,13 +41,122 @@ describe('inventory-control ammo selection', () => { const depleted = option('standard:first', 'Standard', 0); const otherMunition = option('precision:first', 'Precision', 10); - expect(resolveInventoryControlSelectedAmmoOption([depleted, otherMunition], depleted.id)).toBe(depleted); + expect(resolveInventoryControlSelectedAmmoOption( + [depleted, otherMunition], + depleted.profileId, + depleted.id, + )).toBe(depleted); }); it('keeps the only option even when destroyed', () => { const destroyed = { ...option('standard:first', 'Standard', 0), destroyed: true, disabled: true }; - expect(resolveInventoryControlSelectedAmmoOption([destroyed], destroyed.id)).toBe(destroyed); + expect(resolveInventoryControlSelectedAmmoOption( + [destroyed], + destroyed.profileId, + destroyed.id, + )).toBe(destroyed); + }); + + it('keeps the selected profile when its preferred source disappears', () => { + const movedSource = option('standard:new-location', 'Standard', 4); + const otherMunition = option('precision:first', 'Precision', 10); + + expect(resolveInventoryControlSelectedAmmoOption( + [otherMunition, movedSource], + movedSource.profileId, + 'standard:removed-location', + )).toBe(movedSource); + }); + + it('does not substitute another source profile for an authoritative selected profile', () => { + const depletedStandard = option('standard:first', 'Standard', 0); + const usablePrecision = option('precision:first', 'Precision', 10); + + expect(resolveInventoryControlSelectedAmmoOption( + [depletedStandard, usablePrecision], + 'removed-profile', + 'removed-source', + )).toBeUndefined(); + }); + + it('uses identical profile keys for equivalent ammo regardless of source identity', () => { + const first = option('standard:left', 'Standard', 1); + const second = option('standard:right', 'Standard', 10); + + expect(getInventoryControlAmmoProfileId(first.ammo!)) + .toBe(getInventoryControlAmmoProfileId(second.ammo!)); + }); + + it('resolves options from snapshot profile IDs without inspecting ammo definitions', () => { + const depleted = option('standard:first', 'Standard', 0); + const usable = option('standard:second', 'Standard', 10); + const depletedIterationCount = countMunitionIterations(depleted.ammo!); + const usableIterationCount = countMunitionIterations(usable.ammo!); + + expect(resolveInventoryControlSelectedAmmoOption( + [depleted, usable], + depleted.profileId, + depleted.id, + )).toBe(usable); + expect(depletedIterationCount()).toBe(0); + expect(usableIterationCount()).toBe(0); + }); + + it('uses one stable profile for multiple bins sharing an ammo definition', () => { + const weapon = new WeaponEquipment({ + id: 'AC5', name: 'AC/5', type: 'weapon', + weapon: { ammoType: 'AC', rackSize: 5, damage: 5 } + }); + const ammo = new AmmoEquipment({ + id: 'AC5 Ammo', name: 'AC/5 Ammo', type: 'ammo', + ammo: { type: 'AC', rackSize: 5, shots: 20, munitionType: ['M_STANDARD'] } + }); + const inventory: Array = []; + const owner = { + getInventory: () => inventory, + getCritSlots: () => [], + isEquipmentOperational: () => true, + } as unknown as CBTForceUnit; + const mountedWeapon = new MountedWeapon({ owner, id: 'ac5', name: weapon.name, equipment: weapon }); + inventory.push( + mountedWeapon, + new MountedAmmo({ owner, id: 'ammo:left', name: ammo.name, equipment: ammo, totalAmmo: 20 }), + new MountedAmmo({ owner, id: 'ammo:right', name: ammo.name, equipment: ammo, totalAmmo: 20 }), + ); + const summary = getInventoryControlModeAmmoSummary( + mountedWeapon, + new EquipmentRegistry({ [ammo.internalName]: ammo }), + {}, + null, + ); + + expect(summary.options[0].profileId).toBe('AC5 Ammo||M_STANDARD'); + }); + + it('sorts and normalizes fields when creating an ammo profile ID', () => { + const ammo = new AmmoEquipment({ + id: 'Standard', + name: 'Standard', + type: 'ammo', + ammo: { + type: 'AC', + shots: 10, + subMunition: ' Artemis ', + munitionType: ['M_STANDARD', 'M_CLUSTER'] + } + }); + + expect(getInventoryControlAmmoProfileId(ammo)).toBe('Standard|artemis|M_CLUSTER,M_STANDARD'); + }); + + it('handles null and undefined submunition data', () => { + for (const subMunition of [null, undefined]) { + const ammo = option('standard:missing-submunition', 'Standard', 1).ammo!; + (ammo.ammo as { subMunition: string | null | undefined }).subMunition = subMunition; + + expect(getInventoryControlAmmoProfileId(ammo)).toBe('Standard||'); + } }); it('does not synthesize ammo for an unmaterialized one-shot weapon', () => { @@ -53,7 +171,7 @@ describe('inventory-control ammo selection', () => { const owner = { getCritSlots: () => [], getInventory: () => [], - isEquipmentUnavailable: () => false + isEquipmentOperational: () => true } as unknown as CBTForceUnit; const mounted = new MountedWeapon({ owner, id: weapon.id, name: weapon.name, equipment: weapon }); @@ -80,7 +198,7 @@ describe('inventory-control ammo selection', () => { getInventory: () => inventory, getCritSlots: () => [], getUnit: () => createEmptyUnit({ subtype: 'Battle Armor' }), - isEquipmentUnavailable: () => false, + isEquipmentOperational: () => true, } as unknown as CBTForceUnit; const mountedWeapon = new MountedWeapon({ owner, id: 'lrm-os', name: weapon.internalName, equipment: weapon }); const intrinsicAmmo = new MountedAmmo({ @@ -111,18 +229,33 @@ describe('inventory-control ammo selection', () => { }); function option(id: string, internalName: string, remaining: number): InventoryControlAmmoOption { + const ammo = new AmmoEquipment({ + id: internalName, + name: internalName, + type: 'ammo', + ammo: { type: 'AC', shots: 10 } + }); return { id, + profileId: getInventoryControlAmmoProfileId(ammo), label: internalName, - ammo: new AmmoEquipment({ - id: internalName, - name: internalName, - type: 'ammo', - ammo: { type: 'AC', shots: 10 } - }), + ammo, remaining, total: 10, destroyed: false, disabled: false }; } + +function countMunitionIterations(ammo: AmmoEquipment): () => number { + const originalIterator = ammo.munitionType[Symbol.iterator].bind(ammo.munitionType); + let count = 0; + Object.defineProperty(ammo.munitionType, Symbol.iterator, { + configurable: true, + value: () => { + count++; + return originalIterator(); + } + }); + return () => count; +} diff --git a/src/app/utils/inventory-control.util.ts b/src/app/utils/inventory-control.util.ts index 3f9bd0e4f..a398c8693 100644 --- a/src/app/utils/inventory-control.util.ts +++ b/src/app/utils/inventory-control.util.ts @@ -10,10 +10,9 @@ import { MountedAmmo, MountedEquipment, MountedWeapon } from '../models/mounted- import { parseInventoryComponentReference } from '../models/inventory-component-reference.model'; import { type CriticalSlot } from '../models/force-serialization'; import type { UnitComponent } from '../models/units.model'; -import type { InventoryControlRuntimeEntryState, InventoryControlRuntimeRangeKey, InventoryControlRuntimeTarget, InventoryControlRuntimeTargetId } from '../models/inventory-control-runtime-state.model'; +import type { InventoryControlRuntimeAmmoSelection, InventoryControlRuntimeEntryState, InventoryControlRuntimeRangeKey, InventoryControlRuntimeTarget, InventoryControlRuntimeTargetId } from '../models/inventory-control-runtime-state.model'; import type { ToHitAdjustment, ToHitModifierBreakdownEntry, ToHitResolution } from '../models/rules/game-rules'; import { FIELD_GUN_LOCATION, InfantryRules } from '../models/rules/infantry-rules'; -import type { MountedEquipmentToHit } from '../models/rules/unit-type-rules'; import { getBattleArmorTrooperNumber } from '../models/battle-armor-location.model'; import { formatBattleArmorTrooperLocation, @@ -22,6 +21,7 @@ import { } from './ammo-interaction.util'; import { resolveInventoryControlWeaponDamage, type InventoryControlDamageRules } from './inventory-control-damage.util'; import type { WeaponDamage } from '../models/equipment.model'; +import { combineEquipmentStatuses, type EquipmentStatus } from '../models/equipment-status.model'; import { formatInventoryControlHeat, resolveInventoryControlHeatEffect, type InventoryControlHeatRules } from './inventory-control-heat.util'; import type { InventoryControlPhysicalDamageEffect } from './inventory-control-physical-damage.util'; import { ATM_AMMO_PROFILES, MML_AMMO_PROFILES, resolveAmmoWeaponProfile, type AmmoWeaponProfile } from '../models/ammo-weapon-profile.model'; @@ -55,7 +55,7 @@ export interface InventoryControlMode { export interface InventoryControlModifier { name: string; - destroyed: boolean; + status: EquipmentStatus; } export interface InventoryControlDisplayData { @@ -79,6 +79,7 @@ export interface InventoryControlAmmoSummary { export interface InventoryControlAmmoOption { id: string; + profileId: string; label: string; ammo?: AmmoEquipment; remaining: number; @@ -87,6 +88,21 @@ export interface InventoryControlAmmoOption { disabled: boolean; } +export interface InventoryControlAmmoProfileOption { + readonly profileId: string; + readonly ammo: AmmoEquipment; +} + +export interface InventoryControlAmmoSelectionOption extends InventoryControlAmmoProfileOption { + readonly id: string; + readonly usable: boolean; +} + +export interface InventoryControlAmmoSelectionCandidates { + readonly sourceOptions: readonly InventoryControlAmmoSelectionOption[]; + readonly profileOptions: readonly InventoryControlAmmoProfileOption[]; +} + export interface InventoryControlRow { id: string; entry: MountedEquipment; @@ -102,8 +118,7 @@ export interface InventoryControlRow { damageTypes: WeaponType[]; firingHeat: number | null; heatWeakened: boolean; - additionalHitModifier: number; - hitModifierBreakdown?: readonly ToHitModifierBreakdownEntry[]; + hitModifierBreakdown: readonly ToHitModifierBreakdownEntry[]; hitResolution: ToHitResolution; selectedAmmoOption?: InventoryControlAmmoOption; modes: InventoryControlMode[]; @@ -122,6 +137,7 @@ export interface InventoryControlGroup { interface AmmoSource { id: string; + profileId: string; ammo: AmmoEquipment; locationLabel: string; total: number; @@ -138,7 +154,7 @@ interface InventoryControlRowOptions { export interface InventoryControlDisplayEffectOptions { selectedRange: InventoryControlRuntimeRangeKey | null; - additionalHitModifier: number; + hitModifierBreakdown: readonly ToHitModifierBreakdownEntry[]; selectedAmmo?: AmmoEquipment | null; } @@ -159,6 +175,8 @@ export interface InventoryControlRules extends InventoryControlDamageRules, Inve ) => InventoryControlPhysicalDamageEffect; } +export type InventoryControlAmmoMatcher = NonNullable; + const GROUP_TITLES: Record = { ranged: 'Ranged Weapons', physical: 'Physical Weapons', @@ -190,15 +208,14 @@ export function getInventoryControlGroups( equipmentCatalog: EquipmentRegistry, rules: InventoryControlRules = {} ): InventoryControlGroup[] { - const equipmentToHits = unit.rules.getEquipmentToHits(); const ammoSources = getAmmoSources(unit, equipmentCatalog); const rows = unit.getInventory() .map((entry, index) => { const locationLock = getBattleArmorWeaponLocation(entry); - return buildInventoryControlRow(entry, index, equipmentToHits, ammoSources, rules, equipmentCatalog, { + return buildInventoryControlRow(entry, index, ammoSources, rules, equipmentCatalog, { locationLock, destroyed: locationLock - ? unit.isEquipmentUnavailable(entry, locationLock) + ? !unit.isEquipmentOperationalAtLocation(entry, locationLock) : undefined, }); }) @@ -224,7 +241,8 @@ export function selectInventoryControlEntry( chooseTarget?: (selectedTargetId: InventoryControlRuntimeTargetId | null, targets: readonly InventoryControlRuntimeTarget[]) => void, forceSelected = false ): boolean { - if (!isInventoryControlSelectableEntry(entry) || entry.isActionUnavailable()) return false; + if (!isInventoryControlSelectableEntry(entry) + || !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire')) return false; const targets = unit.getInventoryControlTargets(); if (targets.length === 0) { @@ -260,19 +278,105 @@ export function getInventoryControlModes(entry: MountedEquipment): InventoryCont export function getSelectedInventoryControlMode( entry: MountedEquipment, equipmentCatalog: EquipmentRegistry, - rules: InventoryControlRules = {} + matchesAmmo?: InventoryControlAmmoMatcher ): string | null { + const intrinsicAmmo = getIntrinsicOneShotAmmoMount(entry); const ammoSources = entry.equipment instanceof WeaponEquipment && entry.equipment.ammoType === 'MML' - ? getAmmoSources(entry.owner, equipmentCatalog) + ? intrinsicAmmo + ? [createInventoryAmmoSource(intrinsicAmmo, equipmentCatalog, false)].filter((source): source is AmmoSource => source !== null) + : getAmmoSources(entry.owner, equipmentCatalog, false) : []; - return getSelectedMode(entry, getInventoryControlModes(entry), ammoSources, rules.matchesAmmo); + return getSelectedMode(entry, getInventoryControlModes(entry), ammoSources, matchesAmmo); +} + +export function resolveInventoryControlSelectedAmmoType( + entry: MountedEquipment, + equipmentCatalog: EquipmentRegistry, + matchesAmmo?: InventoryControlAmmoMatcher, + selection?: InventoryControlRuntimeAmmoSelection, + mode?: string | null, +): AmmoEquipment | null { + const candidates = getInventoryControlAmmoSelectionCandidates( + entry, + equipmentCatalog, + matchesAmmo, + mode, + false, + ); + const profileId = resolveInventoryControlSelectedProfileId( + candidates.profileOptions, + selection?.selectedProfileId, + selection?.preferredSourceOptionId, + candidates.sourceOptions, + ); + return candidates.profileOptions.find(option => option.profileId === profileId)?.ammo ?? null; +} + +export function getInventoryControlAmmoSelectionOptions( + entry: MountedEquipment, + equipmentCatalog: EquipmentRegistry, + matchesAmmo?: InventoryControlAmmoMatcher, + mode?: string | null, +): readonly InventoryControlAmmoSelectionOption[] { + return getInventoryControlAmmoSelectionCandidates( + entry, + equipmentCatalog, + matchesAmmo, + mode, + true, + ).sourceOptions; +} + +export function getInventoryControlAmmoSelectionCandidates( + entry: MountedEquipment, + equipmentCatalog: EquipmentRegistry, + matchesAmmo?: InventoryControlAmmoMatcher, + mode?: string | null, + resolveSourceUsability = true, +): InventoryControlAmmoSelectionCandidates { + if (!(entry.equipment instanceof WeaponEquipment) || entry.equipment.ammoType === 'NA') { + return { sourceOptions: [], profileOptions: [] }; + } + const intrinsicAmmo = getIntrinsicOneShotAmmoMount(entry); + const sources = intrinsicAmmo + ? [createInventoryAmmoSource(intrinsicAmmo, equipmentCatalog, resolveSourceUsability)] + .filter((source): source is AmmoSource => source !== null) + : getAmmoSources(entry.owner, equipmentCatalog, resolveSourceUsability); + const selectedMode = mode ?? getSelectedMode( + entry, + getInventoryControlModes(entry), + sources, + matchesAmmo, + getBattleArmorWeaponLocation(entry), + ); + const locationLock = getBattleArmorWeaponLocation(entry); + const compatibleSources = sources + .filter(source => ammoMatchesWeaponMode(entry, source.ammo, selectedMode, matchesAmmo)); + const sourceOptions = groupAmmoSources(compatibleSources + .filter(source => !locationLock || source.locationLabel === locationLock)) + .map(source => ({ + id: source.id, + profileId: source.profileId, + ammo: source.ammo, + usable: !resolveSourceUsability || (!source.destroyed && source.total > source.consumed), + })); + const compatibleCatalogAmmo = intrinsicAmmo + ? [] + : equipmentCatalog.getAmmoForWeapon(entry.equipment) + .filter(ammo => ammoMatchesWeaponMode(entry, ammo, selectedMode, matchesAmmo)); + const profileOptions = createInventoryControlAmmoProfileOptions([ + ...compatibleSources.map(source => source.ammo), + ...compatibleCatalogAmmo, + ]); + + return { sourceOptions, profileOptions }; } export function getInventoryControlModeAmmoSummary( entry: MountedEquipment, equipmentCatalog: EquipmentRegistry, rules: InventoryControlRules = {}, - mode: string | null = getSelectedInventoryControlMode(entry, equipmentCatalog, rules) + mode: string | null = getSelectedInventoryControlMode(entry, equipmentCatalog, rules.matchesAmmo) ): InventoryControlAmmoSummary { return getInventoryControlAmmoSummary(entry, getAmmoSources(entry.owner, equipmentCatalog), mode, equipmentCatalog, rules.matchesAmmo); } @@ -316,6 +420,7 @@ function createAmmoSummary(matchingAmmo: AmmoSource[]): InventoryControlAmmoSumm total: availableAmmo.reduce((sum, source) => sum + source.total, 0), options: groupedAmmo.map(source => ({ id: source.id, + profileId: source.profileId, label: formatAmmoOptionLabel(source, locationSensitiveAmmoNames.has(source.ammo.shortName)), ammo: source.ammo, remaining: source.destroyed ? 0 : Math.max(0, source.total - source.consumed), @@ -326,38 +431,62 @@ function createAmmoSummary(matchingAmmo: AmmoSource[]): InventoryControlAmmoSumm }; } -export function resolveInventoryControlSelectedAmmoOption(options: readonly InventoryControlAmmoOption[], selectedOptionId?: string): InventoryControlAmmoOption | undefined { - const selectedOption = selectedOptionId - ? options.find(option => option.id === selectedOptionId) +export function getInventoryControlAmmoProfileId(ammo: AmmoEquipment): string { + const munitions = [...ammo.munitionType].sort().join(','); + const subMunition = (ammo.ammo.subMunition ?? '').trim().toLowerCase(); + return `${ammo.internalName}|${subMunition}|${munitions}`; +} + +export function resolveInventoryControlSelectedAmmoOption( + options: readonly InventoryControlAmmoOption[], + selectedProfileId?: string | null, + preferredSourceOptionId?: string | null, +): InventoryControlAmmoOption | undefined { + if (options.length === 0) return undefined; + const preferredSource = preferredSourceOptionId + ? options.find(option => option.id === preferredSourceOptionId) : undefined; - if (selectedOption && (!hasUsableInventoryControlAmmoOption(options) || isUsableInventoryControlAmmoOption(selectedOption))) { - return selectedOption; + const effectiveProfileId = selectedProfileId ?? preferredSource?.profileId ?? options[0].profileId; + const selectedProfile = options.filter(option => option.profileId === effectiveProfileId); + if (preferredSource && selectedProfile.includes(preferredSource) + && (!selectedProfile.some(isUsableInventoryControlAmmoOption) || isUsableInventoryControlAmmoOption(preferredSource))) { + return preferredSource; } - if (selectedOption) { - return preferredInventoryControlAmmoOption(options, selectedOption) ?? selectedOption; + const usableProfileSource = selectedProfile.find(isUsableInventoryControlAmmoOption); + if (usableProfileSource) return usableProfileSource; + return selectedProfile.find(option => !option.destroyed) ?? selectedProfile[0]; +} + +function resolveInventoryControlSelectedProfileId( + profileOptions: readonly { profileId: string }[], + selectedProfileId?: string | null, + preferredSourceOptionId?: string | null, + sourceOptions: readonly { id: string; profileId: string }[] = [], +): string | undefined { + const preferredSource = preferredSourceOptionId + ? sourceOptions.find(option => option.id === preferredSourceOptionId) + : undefined; + const requestedProfileId = selectedProfileId ?? preferredSource?.profileId; + return requestedProfileId && profileOptions.some(option => option.profileId === requestedProfileId) + ? requestedProfileId + : profileOptions[0]?.profileId; +} + +function createInventoryControlAmmoProfileOptions( + ammoCandidates: readonly AmmoEquipment[], +): InventoryControlAmmoProfileOption[] { + const profiles = new Map(); + for (const ammo of ammoCandidates) { + const profileId = getInventoryControlAmmoProfileId(ammo); + if (!profiles.has(profileId)) profiles.set(profileId, { profileId, ammo }); } - return preferredInventoryControlAmmoOption(options); -} - -function hasUsableInventoryControlAmmoOption(options: readonly InventoryControlAmmoOption[]): boolean { - return options.some(option => isUsableInventoryControlAmmoOption(option)); + return [...profiles.values()]; } function isUsableInventoryControlAmmoOption(option: InventoryControlAmmoOption): boolean { return !option.destroyed && option.remaining > 0; } -function preferredInventoryControlAmmoOption(options: readonly InventoryControlAmmoOption[], sameTypeAs?: InventoryControlAmmoOption): InventoryControlAmmoOption | undefined { - return options.find(option => isUsableInventoryControlAmmoOption(option) - && (!sameTypeAs || inventoryControlAmmoTypeKey(option) === inventoryControlAmmoTypeKey(sameTypeAs))) - ?? (sameTypeAs ? undefined : options.find(option => !option.destroyed) ?? options[0]); -} - -function inventoryControlAmmoTypeKey(option: InventoryControlAmmoOption): string { - return option.ammo?.internalName ?? option.id; -} - - function groupAmmoSources(sources: AmmoSource[]): AmmoSource[] { type GroupedAmmoSource = AmmoSource & { destroyedCount: number; sourceCount: number }; const groups: GroupedAmmoSource[] = []; @@ -448,7 +577,6 @@ function compareRows(a: InventoryControlRow, b: InventoryControlRow, groupId: In function buildInventoryControlRow( entry: MountedEquipment, originalIndex: number, - equipmentToHits: Map, ammoSources: AmmoSource[], rules: InventoryControlRules, equipmentCatalog: EquipmentRegistry, @@ -462,24 +590,25 @@ function buildInventoryControlRow( if (entry.el && !entry.el.classList.contains('inventoryEntry') && !fieldGunComponent && !linkedWeaponEnhancement) return null; if (!entry.el && !fieldGunComponent && !hasModelDisplay) return null; - const status = unitRules.getEquipmentStatus(entry); - const toHit = equipmentToHits.get(entry) ?? unitRules.getEquipmentToHit(entry); + const status = entry.owner.getEquipmentStatus(entry); + const hitModifierBreakdown = unitRules.getEquipmentToHitModifiers(entry); const destroyed = options.destroyed ?? status === 'destroyed'; - const disabled = entry.isActionUnavailable() + const disabled = !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire') || status === 'disabled' || rules.isSelectable?.(entry) === false; const category = getEntryCategory(entry); const { modes, modifiers } = readInventoryControlModesAndModifiers(entry); const selectedMode = getSelectedMode(entry, modes, ammoSources, rules.matchesAmmo, options.locationLock); - syncSvgMode(entry, selectedMode, disabled); const ammo = getInventoryControlAmmoSummary(entry, ammoSources, selectedMode, equipmentCatalog, rules.matchesAmmo, options.locationLock); - const selectedAmmoOption = resolveInventoryControlSelectedAmmoOption(ammo.options, entry.owner.getInventoryControlEntryAmmoOption?.(entry.id)); - const selectedAmmo = selectedAmmoOption?.ammo ?? null; - const additionalHitModifier = toHit.modifier; - const hitModifierBreakdown = toHit.modifiers; + const ammoSelection = entry.owner.getInventoryControlEntryAmmoSelection?.(entry.id); + const selectedAmmo = entry.owner.getInventoryControlSelectedAmmo(entry, selectedMode); + const selectedAmmoOption = resolveInventoryControlSelectedAmmoOption( + ammo.options, + selectedAmmo ? getInventoryControlAmmoProfileId(selectedAmmo) : ammoSelection?.selectedProfileId, + ammoSelection?.preferredSourceOptionId, + ); const hitResolution = resolveInventoryControlHitModifier( entry, - additionalHitModifier, hitModifierBreakdown, selectedAmmo, rules @@ -520,7 +649,7 @@ function buildInventoryControlRow( }; const adjustedDisplay = applyInventoryControlDisplayEffects(entry, resolvedDisplay, { selectedRange, - additionalHitModifier, + hitModifierBreakdown, selectedAmmo }, rules); @@ -529,7 +658,6 @@ function buildInventoryControlRow( entry, category, tracksAmmo: ammo.tracksAmmo, - additionalHitModifier, hitModifierBreakdown, destroyed, disabled, @@ -553,15 +681,13 @@ function buildInventoryControlRow( function resolveInventoryControlHitModifier( entry: MountedEquipment, - additionalHitModifier: number, hitModifierBreakdown: readonly ToHitModifierBreakdownEntry[], selectedAmmo: AmmoEquipment | null, rules: InventoryControlRules ): ToHitResolution { return entry.owner.gameRules.resolveToHit({ subject: entry, - stateModifier: additionalHitModifier, - stateModifierBreakdown: hitModifierBreakdown, + stateModifiers: hitModifierBreakdown, adjustments: rules.resolveToHitAdjustments?.(entry, selectedAmmo) }); } @@ -598,13 +724,11 @@ function getSelectedMode( if (persistedMode && modes.some(mode => mode.mode === persistedMode)) return persistedMode; if (entry.equipment instanceof WeaponEquipment && entry.equipment.ammoType === 'MML') { - const hasUsableLrmAmmo = ammoSources.some(source => + const hasLrmAmmo = ammoSources.some(source => (!locationLock || source.locationLabel === locationLock) - && !source.destroyed - && source.total - source.consumed > 0 && ammoMatchesWeaponMode(entry, source.ammo, 'LRM', matchesAmmo) && resolveAmmoWeaponProfile(source.ammo)?.id === 'mml-lrm'); - return hasUsableLrmAmmo ? 'LRM' : 'SRM'; + return hasLrmAmmo ? 'LRM' : 'SRM'; } if (entry.equipment instanceof WeaponEquipment && (entry.equipment.ammoType === 'ATM' || entry.equipment.ammoType === 'IATM')) return 'Standard'; @@ -750,7 +874,7 @@ function readAlternativeModes(entry: MountedEquipment): { modes: InventoryContro data.name = mode; if (!hasModeData(data)) { - modifiers.push({ name: data.name, destroyed: isModifierDestroyed(entry, data.name) }); + modifiers.push({ name: data.name, status: getModifierStatus(entry, data.name) }); } }); @@ -762,7 +886,7 @@ function readLinkedWeaponEnhancementModifiers(entry: MountedEquipment): Inventor ?.filter(isWeaponEnhancement) .map(linked => ({ name: readLinkedModifierName(linked), - destroyed: linked.isUnavailable() + status: linked.owner.getEquipmentStatus(linked) })) ?? []; } @@ -778,50 +902,61 @@ function isWeaponEnhancement(entry: MountedEquipment): boolean { return !!entry.equipment?.flags.has('F_WEAPON_ENHANCEMENT'); } -function isModifierDestroyed(entry: MountedEquipment, modifierName: string): boolean { +function getModifierStatus(entry: MountedEquipment, modifierName: string): EquipmentStatus { const normalizedModifier = normalizeEquipmentName(modifierName); - return !!entry.linkedWith?.some(linked => { + const statuses = entry.linkedWith?.flatMap(linked => { const linkedNames = [ linked.name, linked.equipment?.name, linked.equipment?.shortName, linked.el ? readDirectText(linked.el, '.name') : '' ]; - return linked.isUnavailable() && linkedNames.some(name => { + const matches = linkedNames.some(name => { const normalizedLinkedName = normalizeEquipmentName(name ?? ''); return normalizedLinkedName.length > 0 && (normalizedModifier.includes(normalizedLinkedName) || normalizedLinkedName.includes(normalizedModifier)); }); - }); + return matches ? [linked.owner.getEquipmentStatus(linked)] : []; + }) ?? []; + return combineEquipmentStatuses(statuses); } -function getAmmoSources(unit: CBTForceUnit, equipmentCatalog: EquipmentRegistry): AmmoSource[] { +function getAmmoSources(unit: CBTForceUnit, equipmentCatalog: EquipmentRegistry, resolveAvailability = true): AmmoSource[] { const critSources = unit.getCritSlots() - .map(criticalSlot => createCriticalSlotAmmoSource(unit, criticalSlot)) + .map(criticalSlot => createCriticalSlotAmmoSource(unit, criticalSlot, resolveAvailability)) .filter((source): source is AmmoSource => !!source); const inventorySources = unit.getInventory() .filter(entry => !isIntrinsicOneShotAmmoMount(entry)) - .map(entry => createInventoryAmmoSource(entry, equipmentCatalog)) + .map(entry => createInventoryAmmoSource(entry, equipmentCatalog, resolveAvailability)) .filter((source): source is AmmoSource => !!source); return [...critSources, ...inventorySources]; } -function createCriticalSlotAmmoSource(unit: CBTForceUnit, criticalSlot: CriticalSlot): AmmoSource | null { +function createCriticalSlotAmmoSource( + unit: CBTForceUnit, + criticalSlot: CriticalSlot, + resolveAvailability = true +): AmmoSource | null { if (!(criticalSlot.eq instanceof AmmoEquipment)) return null; const elementTotal = Number(criticalSlot.el?.getAttribute('totalAmmo') ?? 0); return { id: `crit:${criticalSlot.loc ?? ''}:${criticalSlot.slot ?? ''}:${criticalSlot.name ?? criticalSlot.id}`, + profileId: getInventoryControlAmmoProfileId(criticalSlot.eq), ammo: criticalSlot.eq, locationLabel: criticalSlot.loc ?? 'Ammo', total: criticalSlot.totalAmmo || elementTotal || 0, consumed: criticalSlot.consumed ?? 0, - destroyed: unit.isEquipmentUnavailable(criticalSlot), + destroyed: resolveAvailability && !unit.isEquipmentOperational(criticalSlot), intrinsicOneShotAmmo: false, }; } -function createInventoryAmmoSource(entry: MountedEquipment, equipmentCatalog: EquipmentRegistry): AmmoSource | null { +function createInventoryAmmoSource( + entry: MountedEquipment, + equipmentCatalog: EquipmentRegistry, + resolveAvailability = true +): AmmoSource | null { const currentAmmo = entry.ammo ? equipmentCatalog.findEquipment(entry.ammo) : entry.equipment; const ammo = currentAmmo instanceof AmmoEquipment ? currentAmmo @@ -834,14 +969,15 @@ function createInventoryAmmoSource(entry: MountedEquipment, equipmentCatalog: Eq const locationLabel = Array.from(entry.locations ?? []).join('/') || 'Ammo'; return { id: `inventory:${entry.id}`, + profileId: getInventoryControlAmmoProfileId(ammo), ammo, locationLabel, total, consumed: entry.consumed ?? 0, - destroyed: entry.owner.isEquipmentUnavailable(entry) + destroyed: resolveAvailability && (!entry.owner.isEquipmentOperational(entry) || (isIntrinsicOneShotAmmoMount(entry) && !!entry.parent - && entry.owner.isEquipmentUnavailable(entry.parent)), + && !entry.owner.isEquipmentOperational(entry.parent))), intrinsicOneShotAmmo: isIntrinsicOneShotAmmoMount(entry), }; } @@ -926,7 +1062,7 @@ function applyInventoryControlDisplayEffects( entry, display, options.selectedRange, - options.additionalHitModifier, + options.hitModifierBreakdown, options.selectedAmmo, rules.resolveToHitAdjustments ); @@ -938,7 +1074,7 @@ function applySelectedRangeDisplay( entry: MountedEquipment, display: InventoryControlDisplayData, selectedRange: InventoryControlRuntimeRangeKey | null, - additionalHitModifier: number, + hitModifierBreakdown: readonly ToHitModifierBreakdownEntry[], selectedAmmo?: AmmoEquipment | null, resolveToHitAdjustments?: (entry: MountedEquipment, selectedAmmo?: AmmoEquipment | null) => readonly ToHitAdjustment[] ): InventoryControlDisplayData { @@ -946,7 +1082,7 @@ function applySelectedRangeDisplay( ? display.hit : formatHitModifier(entry.owner.gameRules.resolveToHit({ subject: entry, - stateModifier: additionalHitModifier, + stateModifiers: hitModifierBreakdown, range: selectedRange, adjustments: resolveToHitAdjustments?.(entry, selectedAmmo) }).value); @@ -1005,7 +1141,7 @@ export function formatHitModifier(hitModifier: number | 'Vs' | '*' | null): stri export function syncSvgMode( entry: MountedEquipment, mode: string | null, - disabled = entry.isActionUnavailable() + disabled = !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire') ): void { const el = entry.el; if (!el) return; diff --git a/src/app/utils/inventory-target-number.util.spec.ts b/src/app/utils/inventory-target-number.util.spec.ts index ae4ba86dd..499ceb481 100644 --- a/src/app/utils/inventory-target-number.util.spec.ts +++ b/src/app/utils/inventory-target-number.util.spec.ts @@ -4,13 +4,25 @@ import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; -import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; -import { createTestEquipmentRules } from '../testing/unit-test-helpers'; +import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules, type HitModifier, type ToHitModifierBreakdownEntry, type ToHitResolution } from '../models/rules/game-rules'; import type { InventoryTargetNumberInput } from './inventory-target-number.util'; import { inventoryTargetNumberBreakdown, inventoryTargetNumberState, inventoryTargetRangeSelection } from './inventory-target-number.util'; +function toHitResolution( + value: HitModifier = 0, + modifierBreakdown: readonly ToHitModifierBreakdownEntry[] = [] +): ToHitResolution { + return { + profile: typeof value === 'number' ? [value] : [], + value, + changed: false, + weakened: modifierBreakdown.some(entry => entry.weakened === true), + modifierBreakdown, + }; +} + function artilleryInput(distance: number, gameRules: CBTGameRules = CORE_2026_GAME_RULES): InventoryTargetNumberInput { - const owner = { rules: createTestEquipmentRules() } as never; + const owner = {} as never; const equipment = new WeaponEquipment({ id: 'ArrowIV', name: 'Arrow IV', @@ -34,7 +46,7 @@ function artilleryInput(distance: number, gameRules: CBTGameRules = CORE_2026_GA gunnerySkill: 4, pilotingSkill: 5, attackModifierBreakdown: [], - hitModifier: 0, + hitResolution: toHitResolution(), gameRules, }; } @@ -47,7 +59,6 @@ function aeroInput( ): InventoryTargetNumberInput { const owner = { getUnit: () => ({ type: 'Aero' }), - rules: createTestEquipmentRules() } as never; const equipment = new WeaponEquipment({ id: 'AeroWeapon', @@ -71,12 +82,12 @@ function aeroInput( gunnerySkill: 4, pilotingSkill: 5, attackModifierBreakdown: [], - hitModifier: 0, + hitResolution: toHitResolution(), }; } function c3LaserInput(actualDistance: number, c3Distance: number, allowExtremeRange = false): InventoryTargetNumberInput { - const owner = { rules: createTestEquipmentRules() } as never; + const owner = {} as never; const equipment = new WeaponEquipment({ id: 'ERLargeLaser', name: 'ER Large Laser', @@ -93,7 +104,7 @@ function c3LaserInput(actualDistance: number, c3Distance: number, allowExtremeRa gunnerySkill: 4, pilotingSkill: 5, attackModifierBreakdown: [], - hitModifier: 0, + hitResolution: toHitResolution(), c3DegradationSource: 'unit', allowExtremeRange, gameRules: CORE_2026_GAME_RULES, @@ -338,24 +349,23 @@ describe('inventory target number rules profiles', () => { }); it('preserves typed nonnumeric hit outcomes', () => { - expect(inventoryTargetNumberState({ ...artilleryInput(8), hitModifier: 'Vs' }).text).toBe('Vs'); - expect(inventoryTargetNumberState({ ...artilleryInput(8), hitModifier: '*' }).text).toBe('*'); - expect(inventoryTargetNumberState({ ...artilleryInput(8), hitModifier: null }).text).toBe(''); + expect(inventoryTargetNumberState({ ...artilleryInput(8), hitResolution: toHitResolution('Vs') }).text).toBe('Vs'); + expect(inventoryTargetNumberState({ ...artilleryInput(8), hitResolution: toHitResolution('*') }).text).toBe('*'); + expect(inventoryTargetNumberState({ ...artilleryInput(8), hitResolution: toHitResolution(null) }).text).toBe(''); }); it('keeps targets beyond long range out of range before resolving hit state', () => { - expect(inventoryTargetNumberState({ ...artilleryInput(31), hitModifier: 'Vs' }).text).toBe('X'); + expect(inventoryTargetNumberState({ ...artilleryInput(31), hitResolution: toHitResolution('Vs') }).text).toBe('X'); }); it('renders identified hit modifiers as separate lines', () => { const state = inventoryTargetNumberState({ ...artilleryInput(8), selectedAmmo: null, - hitModifier: -2, - hitModifierBreakdown: [ + hitResolution: toHitResolution(-2, [ { label: 'ER Medium Laser', modifier: -1 }, { label: 'Targeting Computer', modifier: -1 } - ] + ]) }); expect(state.breakdown?.total).toBe(2); @@ -368,8 +378,7 @@ describe('inventory target number rules profiles', () => { const state = inventoryTargetNumberState({ ...artilleryInput(8), selectedAmmo: null, - hitModifier: 0, - hitModifierBreakdown: [{ label: 'Targeting Computer Destroyed', modifier: 0, weakened: true }] + hitResolution: toHitResolution(0, [{ label: 'Targeting Computer Destroyed', modifier: 0, weakened: true }]) }); expect(state.breakdown?.total).toBe(4); @@ -378,28 +387,27 @@ describe('inventory target number rules profiles', () => { })); }); - it('falls back to the generic label when source totals are incomplete', () => { + it('keeps the structured breakdown authoritative instead of synthesizing a generic label', () => { const state = inventoryTargetNumberState({ ...artilleryInput(8), selectedAmmo: null, - hitModifier: 2, - hitModifierBreakdown: [{ label: 'Incomplete', modifier: 1 }] + hitResolution: toHitResolution(2, [{ label: 'Damaged Fire Control', modifier: 1, weakened: true }]) }); - expect(state.breakdown?.lines).toContain(jasmine.objectContaining({ label: 'Hit Modifier', value: '+2' })); - expect(state.breakdown?.lines).not.toContain(jasmine.objectContaining({ label: 'Incomplete' })); + expect(state.breakdown?.total).toBe(5); + expect(state.breakdown?.lines).toContain(jasmine.objectContaining({ label: 'Damaged Fire Control', value: '+1' })); + expect(state.breakdown?.lines).not.toContain(jasmine.objectContaining({ label: 'Hit Modifier' })); }); it('orders regular terms before weakened terms and heat last', () => { const state = inventoryTargetNumberState({ ...artilleryInput(8), selectedAmmo: null, - hitModifier: 0, - hitModifierBreakdown: [ + hitResolution: toHitResolution(2, [ { label: 'Damaged Fire Control', modifier: 1, weakened: true }, - { label: 'Targeting Computer', modifier: -1 } - ], - heatFireModifier: 2 + { label: 'Targeting Computer', modifier: -1 }, + { label: 'Heat - Fire Modifier', modifier: 2, weakened: true, kind: 'heat' } + ]) }); expect(state.breakdown?.lines.map(line => line.label ?? (line.isBreak ? 'BREAK' : ''))).toEqual([ diff --git a/src/app/utils/inventory-target-number.util.ts b/src/app/utils/inventory-target-number.util.ts index bddd5bd6c..0a91d1b95 100644 --- a/src/app/utils/inventory-target-number.util.ts +++ b/src/app/utils/inventory-target-number.util.ts @@ -6,7 +6,7 @@ import type { MountedEquipment } from '../models/mounted-equipment.model'; import { WeaponEquipment, type AmmoEquipment } from '../models/equipment.model'; import { resolveAmmoWeaponProfile } from '../models/ammo-weapon-profile.model'; import type { InventoryControlRuntimeRangeKey, InventoryControlRuntimeTarget } from '../models/inventory-control-runtime-state.model'; -import { CORE_2026_GAME_RULES, SKILL_BREAKDOWN_PRIORITY, validatedToHitModifierBreakdown, type C3DegradationSource, type CBTGameRules, type HitModifier, type ToHitModifierBreakdownEntry } from '../models/rules/game-rules'; +import { CORE_2026_GAME_RULES, separateHeatFireModifier, SKILL_BREAKDOWN_PRIORITY, type C3DegradationSource, type CBTGameRules, type ToHitResolution } from '../models/rules/game-rules'; import { modifierTooltipLines, orderHitTargetTooltipLines } from './hit-target-tooltip.util'; import type { UnitModifierBreakdownEntry } from '../models/rules/unit-type-rules'; import type { InventoryControlDisplayData, InventoryControlGroupId, InventoryRangeKey } from './inventory-control.util'; @@ -48,9 +48,7 @@ export interface InventoryTargetNumberInput { pilotingSkill: number; missingMovementModifier?: boolean; attackModifierBreakdown: readonly UnitModifierBreakdownEntry[]; - hitModifier: HitModifier; - hitModifierBreakdown?: readonly ToHitModifierBreakdownEntry[]; - heatFireModifier?: number; + hitResolution: ToHitResolution; c3DegradationSource?: C3DegradationSource; gameRules?: CBTGameRules; } @@ -176,10 +174,11 @@ export function inventoryTargetNumberState( ): InventoryTargetNumberState { if (!rangeSelection) return { text: '', breakdown: null, rangeSelection }; if (rangeSelection.outOfRange) return { text: 'X', breakdown: null, rangeSelection }; - if (input.hitModifier === 'Vs' || input.hitModifier === '*') { - return { text: input.hitModifier, breakdown: null, rangeSelection }; + const { hitModifier } = separateHeatFireModifier(input.hitResolution); + if (hitModifier === 'Vs' || hitModifier === '*') { + return { text: hitModifier, breakdown: null, rangeSelection }; } - if (input.hitModifier === null) return { text: '', breakdown: null, rangeSelection }; + if (hitModifier === null) return { text: '', breakdown: null, rangeSelection }; const breakdown = inventoryTargetNumberBreakdown(input, rangeSelection); if (input.missingMovementModifier) return { text: 'M?', breakdown, rangeSelection }; return { text: breakdown === null ? '' : breakdown.total.toString(), breakdown, rangeSelection }; @@ -196,7 +195,8 @@ export function inventoryTargetNumberBreakdown( const target = input.target; if (!target) return null; if (!rangeSelection) return null; - if (typeof input.hitModifier !== 'number') return null; + const { hitModifier, hitModifierBreakdown, heatFireModifier: separatedHeatFireModifier } = separateHeatFireModifier(input.hitResolution); + if (typeof hitModifier !== 'number') return null; if (input.missingMovementModifier) { return { total: 0, @@ -222,7 +222,7 @@ export function inventoryTargetNumberBreakdown( ? 0 : gameRules.resolveToHit({ subject: input.selectedAmmo, range: rangeSelection.range }).value; const numericAmmoToHitModifier = typeof ammoToHitModifier === 'number' ? ammoToHitModifier : 0; - const heatFireModifier = physical ? 0 : input.heatFireModifier ?? 0; + const heatFireModifier = physical ? 0 : separatedHeatFireModifier; const terms: TooltipLine[] = [ { label: skillLabel, value: skill.toString(), priority: SKILL_BREAKDOWN_PRIORITY } ]; @@ -252,7 +252,6 @@ export function inventoryTargetNumberBreakdown( if (minimumRangeModifier !== 0) { terms.push({ label: 'Minimum Range', value: formatInventoryTargetSignedModifier(minimumRangeModifier), weakened: true }); } - const hitModifierBreakdown = validatedToHitModifierBreakdown(input.hitModifier, input.hitModifierBreakdown); terms.push(...modifierTooltipLines(hitModifierBreakdown, entry => formatInventoryTargetSignedModifier(entry.modifier))); if (numericAmmoToHitModifier !== 0 && input.selectedAmmo) { terms.push({ label: `Ammo (${input.selectedAmmo.shortName})`, value: formatInventoryTargetSignedModifier(numericAmmoToHitModifier) }); @@ -267,7 +266,8 @@ export function inventoryTargetNumberBreakdown( } const attackModifier = input.attackModifierBreakdown.reduce((total, entry) => total + entry.modifier, 0); - const total = skill + attackModifier + target.tnModifier + rangeModifier + c3ModifierValue + minimumRangeModifier + input.hitModifier + numericAmmoToHitModifier + heatFireModifier; + const equipmentHitModifier = hitModifierBreakdown.reduce((total, entry) => total + entry.modifier, 0); + const total = skill + attackModifier + target.tnModifier + rangeModifier + c3ModifierValue + minimumRangeModifier + equipmentHitModifier + numericAmmoToHitModifier + heatFireModifier; return { total, lines: [ diff --git a/src/app/utils/mul-file.util.spec.ts b/src/app/utils/mul-file.util.spec.ts index 88f456a25..c3631aa4a 100644 --- a/src/app/utils/mul-file.util.spec.ts +++ b/src/app/utils/mul-file.util.spec.ts @@ -15,6 +15,7 @@ import { CBTGameRulesService } from '../services/cbt-game-rules.service'; import { CORE_2026_GAME_RULES } from '../models/rules/game-rules'; import { MekRules } from '../models/rules/mek-rules'; import { EquipmentInteractionRegistryService } from '../services/equipment-interaction-registry.service'; +import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; const equipmentInteractionRegistryService = new EquipmentInteractionRegistryService(); @@ -116,6 +117,7 @@ async function getSerializedMulEntity(unit: Unit, crewSlots: number): Promise units, + getEquipmentRegistry: () => EMPTY_EQUIPMENT_REGISTRY, } as any; } From 445f8eca5c74779b69e4a8d3e7a017a967d638ef Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 9 Aug 2026 10:48:47 +0200 Subject: [PATCH 09/12] cleanup and dry/kiss few parts --- .../ammo-loadout-panel.component.spec.ts | 32 ++- .../ammo-loadout-panel.component.ts | 66 ++++- .../weapons-equipment-panel.component.html | 2 +- .../weapons-equipment-panel.component.scss | 4 + .../weapons-equipment-panel.component.spec.ts | 80 +++++- .../weapons-equipment-panel.component.ts | 11 +- .../page-psr-warning-panel.component.ts | 48 +++- .../svg-interaction.service.spec.ts | 72 ++++- .../page-viewer/svg-interaction.service.ts | 45 +-- .../base/cycle-mode.handler.ts | 5 +- .../base/multi-mode.handler.spec.ts | 6 +- .../base/multi-mode.handler.ts | 5 +- .../equipment-handlers/base/toggle.handler.ts | 5 +- .../bombast-laser.handler.spec.ts | 26 +- .../bombast-laser.handler.ts | 15 +- .../disabled-equipment.handler.spec.ts | 22 +- src/app/equipment-handlers/ecm.handler.ts | 5 +- .../laser-insulator.handler.spec.ts | 55 ++-- .../equipment-handlers/masc.handler.spec.ts | 27 +- .../ppc-capacitor.handler.spec.ts | 48 ++-- .../risc-laser-pulse-module.handler.spec.ts | 51 ++-- .../stealth.handler.spec.ts | 7 +- .../uacjamming.handler.spec.ts | 20 +- .../vibroblade.handler.spec.ts | 12 +- .../equipment-handlers/weapon-ammo.handler.ts | 8 +- src/app/models/cbt-force-unit-state.model.ts | 1 + src/app/models/cbt-force-unit.model.spec.ts | 272 +++++++++++++++++- src/app/models/cbt-force-unit.model.ts | 35 ++- ...bt-inventory-control-runtime.model.spec.ts | 16 ++ src/app/models/force-serialization.ts | 2 + .../inventory-control-runtime-state.model.ts | 33 ++- src/app/models/turn-state.model.spec.ts | 21 ++ src/app/models/turn-state.model.ts | 17 +- ...pment-interaction-registry.service.spec.ts | 14 +- src/app/services/unit-svg-mek.service.ts | 21 +- src/app/services/unit-svg-vehicle.service.ts | 21 +- src/app/services/unit-svg.service.ts | 45 ++- src/app/testing/unit-test-helpers.spec.ts | 93 +++++- src/app/testing/unit-test-helpers.ts | 136 +++++++-- src/app/utils/ammo-interaction.util.spec.ts | 50 +++- src/app/utils/ammo-interaction.util.ts | 71 +++-- .../utils/inventory-control-ammo.util.spec.ts | 2 + src/app/utils/inventory-control.util.ts | 88 +++--- 43 files changed, 1164 insertions(+), 451 deletions(-) diff --git a/src/app/components/equipment-dialog/ammo-loadout-panel.component.spec.ts b/src/app/components/equipment-dialog/ammo-loadout-panel.component.spec.ts index 587643534..0b493fec4 100644 --- a/src/app/components/equipment-dialog/ammo-loadout-panel.component.spec.ts +++ b/src/app/components/equipment-dialog/ammo-loadout-panel.component.spec.ts @@ -16,6 +16,7 @@ import { } from '../../services/equipment-interaction-registry.service'; import { AmmoLoadoutPanelComponent, type AmmoLoadoutPanelData } from './ammo-loadout-panel.component'; import type { AmmoControlEntry } from '../../utils/ammo-interaction.util'; +import type { EquipmentStatus } from '../../models/equipment-status.model'; function createAmmo(id: string): AmmoEquipment { return new AmmoEquipment({ @@ -32,6 +33,7 @@ function createCritEntry(params: { ammo: AmmoEquipment; consumed?: number; destroyed?: boolean; + status?: EquipmentStatus; owner: Pick; }): AmmoControlEntry { const owner = params.owner as CBTForceUnit; @@ -64,7 +66,7 @@ function createCritEntry(params: { originalTotalAmmo: 5, totalAmmo: 5, consumed: params.consumed ?? 0, - destroyed: !!params.destroyed, + status: params.status ?? (params.destroyed ? 'destroyed' : 'available'), }; } @@ -140,7 +142,7 @@ describe('AmmoLoadoutPanelComponent', () => { groups = component.groups(); expect(groups.length).toBe(2); expect(groups.map(group => group.displayName)).toEqual(['Clan Ultra AC/20 Precision Ammo', 'Clan Ultra AC/20 Ammo']); - expect(groups.map(group => group.destroyed)).toEqual([false, true]); + expect(groups.map(group => group.status)).toEqual(['available', 'destroyed']); expect(component.groupRemaining(groups[0])).toBe(5); expect(component.groupRemaining(groups[1])).toBe(0); }); @@ -169,6 +171,32 @@ describe('AmmoLoadoutPanelComponent', () => { expect(fixture.nativeElement.querySelector('.ammo-bin-list')).toBeNull(); }); + it('styles a disabled ammo source separately from a destroyed source', () => { + const standardAmmo = createAmmo('Clan Ultra AC/20 Ammo'); + const owner = { + id: 'unit-1', + readOnly: () => false, + getUnit: () => ({ techBase: 'Clan' }), + } as unknown as Pick; + const data: AmmoLoadoutPanelData = { + entries: [createCritEntry({ loc: 'LT', slot: 0, ammo: standardAmmo, owner, status: 'disabled' })], + context: createCommandContext(), + }; + + TestBed.configureTestingModule({ imports: [AmmoLoadoutPanelComponent] }); + const fixture = TestBed.createComponent(AmmoLoadoutPanelComponent); + fixture.componentRef.setInput('data', data); + fixture.detectChanges(); + + const row = fixture.nativeElement.querySelector('.ammo-control-row') as HTMLElement; + const badge = fixture.nativeElement.querySelector('.ammo-location-badge') as HTMLElement; + expect(row.classList.contains('disabled-entry')).toBeTrue(); + expect(row.classList.contains('destroyed-entry')).toBeFalse(); + expect(badge.classList.contains('disabled')).toBeTrue(); + expect(badge.classList.contains('destroyed')).toBeFalse(); + expect(fixture.nativeElement.querySelector('.ammo-control-actions')).toBeNull(); + }); + it('shows location badges beside the group name', () => { const standardAmmo = createAmmo('Clan Ultra AC/20 Ammo'); const owner = { diff --git a/src/app/components/equipment-dialog/ammo-loadout-panel.component.ts b/src/app/components/equipment-dialog/ammo-loadout-panel.component.ts index 4021db3db..36956cce5 100644 --- a/src/app/components/equipment-dialog/ammo-loadout-panel.component.ts +++ b/src/app/components/equipment-dialog/ammo-loadout-panel.component.ts @@ -6,7 +6,7 @@ import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core import type { CBTInventoryControlRuntime } from '../../models/cbt-inventory-control-runtime.model'; import type { HandlerCommandContext } from '../../services/equipment-interaction-registry.service'; import type { AmmoControlEntry, AmmoControlGroup, AmmoControlGroupLocation } from '../../utils/ammo-interaction.util'; -import { changeAmmoEntryRemaining, changeAmmoGroupRemaining, getAmmoControlGroups, getAmmoEntryRemaining, getAmmoGroupRemaining, setAmmoEntry, setAmmoGroup } from '../../utils/ammo-interaction.util'; +import { changeAmmoEntryRemaining, changeAmmoGroupRemaining, getAmmoControlGroups, getAmmoEntryRemaining, getAmmoGroupRemaining, isAmmoControlEntryUsable, setAmmoEntry, setAmmoGroup } from '../../utils/ammo-interaction.util'; export interface AmmoLoadoutPanelData { entries: AmmoControlEntry[]; @@ -30,7 +30,7 @@ export interface AmmoLoadoutPanelData {
@for (group of groups(); track group.id) { @let remainingAmmoGroup = groupRemaining(group); -
+
@if (group.expandable) { - @if (!entry.destroyed && !readOnly()) { + @if (entryUsable(entry) && !readOnly()) {
@@ -99,7 +99,7 @@ export interface AmmoLoadoutPanelData {
}
- @if (!readOnly() && !group.destroyed) { + @if (!readOnly() && group.status === 'available') {
@@ -159,6 +159,12 @@ export interface AmmoLoadoutPanelData { color: var(--damage-color); } + .ammo-control-row.disabled-entry .ammo-name, + .ammo-bin.disabled, + .ammo-control-row.disabled-entry { + color: var(--disabled-color); + } + .ammo-control-label { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -206,6 +212,11 @@ export interface AmmoLoadoutPanelData { color: var(--damage-color); } + .disabled-entry .chevron, + .disabled-entry .no-chevron { + color: var(--disabled-color); + } + .chevron.collapsed { transform: rotate(-90deg); } @@ -255,6 +266,10 @@ export interface AmmoLoadoutPanelData { background: var(--damage-color); } + .ammo-location-badge.disabled { + background: var(--disabled-color); + } + .ammo-control-label > .ammo-name-wrapper { display: flex; align-items: center; @@ -292,6 +307,13 @@ export interface AmmoLoadoutPanelData { color: var(--damage-color); } + .ammo-control-row.disabled-entry > .ammo-control-label > .ammo-count, + .ammo-control-row.disabled-entry > .ammo-control-label > .ammo-count > .count, + .ammo-bin.disabled > .ammo-count, + .ammo-bin.disabled > .ammo-count > .count { + color: var(--disabled-color); + } + .ammo-control-actions { display: flex; gap: 6px; @@ -382,6 +404,10 @@ export interface AmmoLoadoutPanelData { text-decoration-color: var(--damage-color); } + .ammo-bin.disabled .ammo-bin-name { + color: var(--disabled-color); + } + @container (max-width: 520px) { .ammo-control-row { grid-template-columns: 1fr; @@ -466,13 +492,25 @@ export class AmmoLoadoutPanelComponent { return location.state === 'destroyed'; } + isLocationBadgeDisabled(location: AmmoControlGroupLocation): boolean { + return location.state === 'disabled'; + } + isEntryLocationBadgeExposed(group: AmmoControlGroup, entry: AmmoControlEntry): boolean { - if (entry.destroyed) return false; + if (!isAmmoControlEntryUsable(entry)) return false; return group.locations.find(location => location.loc === entry.locationLabel)?.state === 'exposed'; } isEntryLocationBadgeDestroyed(entry: AmmoControlEntry): boolean { - return entry.destroyed; + return entry.status === 'destroyed'; + } + + isEntryLocationBadgeDisabled(entry: AmmoControlEntry): boolean { + return entry.status === 'disabled'; + } + + entryUsable(entry: AmmoControlEntry): boolean { + return isAmmoControlEntryUsable(entry); } decrement(group: AmmoControlGroup): void { @@ -490,14 +528,14 @@ export class AmmoLoadoutPanelComponent { } decrementBin(entry: AmmoControlEntry): void { - if (this.readOnly() || entry.destroyed) return; + if (this.readOnly() || !isAmmoControlEntryUsable(entry)) return; if (changeAmmoEntryRemaining(entry, -1, this.data.context)) { this.markInventoryViewChanged(); } } incrementBin(entry: AmmoControlEntry): void { - if (this.readOnly() || entry.destroyed) return; + if (this.readOnly() || !isAmmoControlEntryUsable(entry)) return; if (changeAmmoEntryRemaining(entry, 1, this.data.context)) { this.markInventoryViewChanged(); } @@ -525,4 +563,4 @@ export class AmmoLoadoutPanelComponent { this.data.inventoryControl?.markInventoryViewChanged(); } -} \ No newline at end of file +} diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.html b/src/app/components/equipment-dialog/weapons-equipment-panel.component.html index 80b52eefb..a07a008aa 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.html +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.html @@ -175,7 +175,7 @@

@if (row.tracksAmmo) { @let ammo = ammoState(row); -
+
@if (ammo.hasAmmo) { @if (ammo.showDropdown) { diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss b/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss index b56f98024..00a8046fc 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.scss @@ -643,6 +643,10 @@ text-decoration-line: line-through; } +.ammo-cell.disabled-ammo .ammo-choice-static { + color: var(--disabled-color); +} + .ammo-stepper-button { flex: 0 0 24px; width: 24px; diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts index 290a7231d..b7a8ab961 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts @@ -536,6 +536,7 @@ describe('WeaponsEquipmentPanelComponent', () => { const { unit } = createCBTForceUnitTestHarness({ components: [...narcEntries, ...ammoEntries], unit: { + type: 'Infantry', subtype: 'Battle Armor', squads: 1, squadSize: 4, @@ -617,6 +618,24 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(uac.el!.classList.contains('disabledInventory')).toBeFalse(); }); + it('uses change-mode rather than fire permission for nonweapon equipment rows', () => { + const ecm = entry({ + id: 'ecm', + equipment: misc('ecm', ['F_ECM']), + el: svgEntry('ECM') + }); + const { unit } = createCBTForceUnitTestHarness({ components: [ecm] }); + const canPerform = spyOn(unit, 'canPerformEquipmentAction') + .and.callFake((_entry, action) => action === 'change-mode'); + + const row = getInventoryControlGroups(unit, new EquipmentRegistry({})) + .find(group => group.id === 'equipment')!.rows[0]; + + expect(row.disabled).toBeFalse(); + expect(canPerform).toHaveBeenCalledWith(ecm, 'change-mode'); + expect(canPerform).not.toHaveBeenCalledWith(ecm, 'fire'); + }); + it('marks direct inventory hits pending before commit', () => { const laser = entry({ id: 'laser', equipment: weapon('laser'), el: svgEntry('Laser') }); const { component, fixture, unit } = createComponent([laser]); @@ -1226,7 +1245,8 @@ describe('WeaponsEquipmentPanelComponent', () => { equipment: weapon('ATM 6', 'ATM', 6), el: svgEntry('Wrong SVG NameStandardExtended Range') }); - const { component, fixture } = createComponent([first, second, modeEntry]); + const { component, fixture, unit } = createComponent([first, second, modeEntry]); + const setInventoryEntry = unit.setInventoryEntry as jasmine.Spy; const group = component.groups().find(candidate => candidate.id === 'ranged')!; component.drop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop, group); @@ -1234,9 +1254,16 @@ describe('WeaponsEquipmentPanelComponent', () => { const rangedSortKey = inventoryControlSortKey('ranged'); expect(first.states.get(rangedSortKey)).toBe('1'); expect(second.states.get(rangedSortKey)).toBe('0'); + expect(setInventoryEntry).toHaveBeenCalledWith(first, { phaseChange: false }); + expect(setInventoryEntry).toHaveBeenCalledWith(second, { phaseChange: false }); + expect(setInventoryEntry).toHaveBeenCalledWith(modeEntry, { phaseChange: false }); + setInventoryEntry.calls.reset(); const row = component.groups().find(candidate => candidate.id === 'ranged')!.rows.find(candidate => candidate.id === 'mode')!; await component.handleChoice(row, { ...component.modeChoice(row)!, value: 'Extended Range', label: 'ER' }); + expect(setInventoryEntry).toHaveBeenCalledOnceWith(modeEntry); + await component.handleChoice(row, { ...component.modeChoice(row)!, value: 'Extended Range', label: 'ER' }); + expect(setInventoryEntry).toHaveBeenCalledTimes(1); component.selectRange(row, 'short'); const updatedRow = component.groups().find(candidate => candidate.id === 'ranged')!.rows.find(candidate => candidate.id === 'mode')!; @@ -2927,8 +2954,12 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(component.ammoState(row).text).toBe(''); expect(component.ammoState(row).depleted).toBeTrue(); expect(component.ammoState(row).destroyed).toBeFalse(); + expect(component.ammoState(row).disabled).toBeFalse(); fixture.detectChanges(); - expect((fixture.nativeElement.querySelector('.ammo-cell') as HTMLElement).textContent?.trim()).toBe('NO AMMO'); + const ammoCell = fixture.nativeElement.querySelector('.ammo-cell') as HTMLElement; + expect(ammoCell.textContent?.trim()).toBe('NO AMMO'); + expect(ammoCell.classList.contains('destroyed-ammo')).toBeFalse(); + expect(ammoCell.classList.contains('disabled-ammo')).toBeFalse(); expect(fixture.nativeElement.querySelectorAll('.ammo-stepper-button').length).toBe(0); }); @@ -2954,12 +2985,53 @@ describe('WeaponsEquipmentPanelComponent', () => { expect(component.ammoState(row).hasAmmo).toBeFalse(); expect(component.ammoState(row).text).toBe(''); expect(component.ammoState(row).depleted).toBeTrue(); - expect(component.ammoState(row).destroyed).toBeFalse(); + expect(component.ammoState(row).destroyed).toBeTrue(); + expect(component.ammoState(row).disabled).toBeFalse(); fixture.detectChanges(); - expect((fixture.nativeElement.querySelector('.ammo-cell') as HTMLElement).textContent?.trim()).toBe('NO AMMO'); + const ammoCell = fixture.nativeElement.querySelector('.ammo-cell') as HTMLElement; + expect(ammoCell.textContent?.trim()).toBe('NO AMMO'); + expect(ammoCell.classList.contains('destroyed-ammo')).toBeTrue(); + expect(ammoCell.classList.contains('disabled-ammo')).toBeFalse(); expect(fixture.nativeElement.querySelectorAll('.ammo-stepper-button').length).toBe(0); }); + it('shows disabled ammo separately from destroyed ammo', () => { + const standardAmmo = ammo('ATM 6 Standard', 'ATM', 6, ['M_STANDARD']); + const atm = entry({ + id: 'atm', + equipment: weapon('ATM 6', 'ATM', 6, [1, 2, 3, 4], 0, 4), + el: svgEntry('ATM 6Standard2/Msl5') + }); + const disabledBin = entry({ + id: 'disabled-ammo', + equipment: standardAmmo, + totalAmmo: 10, + consumed: 0, + locations: new Set(['LT']), + }); + const equipmentMap: EquipmentMap = { [standardAmmo.internalName]: standardAmmo }; + const { component, fixture } = createComponent( + [atm, disabledBin], + equipmentMap, + [], + new Map([[disabledBin, 'disabled']]), + ); + const row = component.groups().find(group => group.id === 'ranged')!.rows[0]; + const [option] = row.ammo.options; + + expect(option).toEqual(jasmine.objectContaining({ + remaining: 0, + destroyed: false, + disabled: true, + })); + expect(component.ammoState(row).destroyed).toBeFalse(); + expect(component.ammoState(row).disabled).toBeTrue(); + fixture.detectChanges(); + const ammoCell = fixture.nativeElement.querySelector('.ammo-cell') as HTMLElement; + expect(ammoCell.classList.contains('disabled-ammo')).toBeTrue(); + expect(ammoCell.classList.contains('destroyed-ammo')).toBeFalse(); + }); + it('groups same-location ammo bins', () => { const lrmAmmo = ammo('MML 9/LRM Artemis', 'MML', 9, [], ['F_MML_LRM']); const srmAmmo = ammo('MML 9/SRM Artemis', 'MML', 9, [], ['F_MML_SRM']); diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts index 78b5d2cc9..edc7b57fd 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.ts @@ -115,6 +115,7 @@ interface AmmoRowState { text: string; depleted: boolean; destroyed: boolean; + disabled: boolean; canDecrease: boolean; canIncrease: boolean; } @@ -668,7 +669,8 @@ export class WeaponsEquipmentPanelComponent { const depleted = row.tracksAmmo ? selectedOption?.remaining !== undefined ? selectedOption.remaining <= 0 : row.ammo.remaining <= 0 : false; - const destroyed = hasUsableAmmo ? !!selectedOption?.destroyed : false; + const destroyed = !!selectedOption?.destroyed; + const disabled = !!selectedOption?.disabled && !destroyed; return { hasAmmo, showDropdown: row.ammo.options.length > 1 && hasUsableAmmo, @@ -677,6 +679,7 @@ export class WeaponsEquipmentPanelComponent { text, depleted, destroyed, + disabled, canDecrease: this.canAdjustResolvedAmmo(row, selectedOption, 1, hasUsableAmmo), canIncrease: this.canAdjustResolvedAmmo(row, selectedOption, -1, hasUsableAmmo), }; @@ -715,7 +718,7 @@ export class WeaponsEquipmentPanelComponent { private canAdjustResolvedAmmo(row: InventoryControlRow, option: InventoryControlAmmoOption | undefined, delta: number, hasUsableAmmo: boolean): boolean { if (this.readOnly() || !row.tracksAmmo || delta === 0) return false; - if (!option || option.destroyed) return false; + if (!option || option.disabled) return false; if (!hasUsableAmmo) return false; if (delta > 0) return option.remaining > 0; return option.remaining < option.total; @@ -749,7 +752,7 @@ export class WeaponsEquipmentPanelComponent { for (const row of selectedRows) { if (!row.tracksAmmo) continue; const option = this.selectedAmmo(row); - if (!option || option.destroyed || option.remaining <= 0) { + if (!option || option.disabled || option.remaining <= 0) { await this.context().commandContext.dialogsService.showError(`${row.display.name} has no available ammo.`, 'No Ammo'); return; } @@ -872,7 +875,7 @@ export class WeaponsEquipmentPanelComponent { } private isUsableAmmoOption(option: InventoryControlAmmoOption): boolean { - return !option.destroyed && option.remaining > 0; + return !option.disabled && option.remaining > 0; } private heatDissipationState(): HeatDissipationState | null { diff --git a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.ts b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.ts index 7400f147a..2356af01e 100644 --- a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.ts +++ b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.ts @@ -68,6 +68,7 @@ export class PagePsrWarningPanelComponent { if (this.rolledResult() === 'FAILED') return 'failed'; return 'default'; }); + private readonly retainedResolvedRuleChecks = signal([]); private rollingCheck: PSRCheck | null = null; readonly locationLabel = getMekLocationLabel; @@ -99,16 +100,26 @@ export class PagePsrWarningPanelComponent { const unit = this.unit(); if (!unit) return; if (check.resolution) { - unit.resolveRuleCheck(check.resolution.key, check.resolution.token, result); + if (unit.resolveRuleCheck(check.resolution.key, check.resolution.token, result)) { + this.retainResolvedRuleCheck(check); + } } else if (check.id) { unit.turnState().resolvePSRCheck(check.id, result); } - if (this.psrChecks().length === 0) this.close(); } outcome(check: PSRCheck) { - if (!check.id || check.resolution) return undefined; - return this.unit()?.turnState().getPSROutcome(check.id); + const unit = this.unit(); + if (!unit) return undefined; + if (check.resolution) { + const ruleCheck = unit.getRuleCheck(check.resolution.key); + if (!ruleCheck || ruleCheck.token !== check.resolution.token || ruleCheck.status === 'pending') { + return undefined; + } + return ruleCheck.status; + } + if (!check.id) return undefined; + return unit.turnState().getPSROutcome(check.id); } isAutomaticFailure(check: PSRCheck): boolean { @@ -130,9 +141,20 @@ export class PagePsrWarningPanelComponent { readonly psrChecks = computed(() => { const unit = this.unit(); if (!unit) return []; - return unit.turnState().getPSRChecks() + const checks = unit.turnState().getPSRChecks() .filter(check => check.fallCheck !== undefined) - .sort((left, right) => this.checkDisplayOrder(left) - this.checkDisplayOrder(right)); + for (const retainedCheck of this.retainedResolvedRuleChecks()) { + if (!retainedCheck.resolution) continue; + const ruleCheck = unit.getRuleCheck(retainedCheck.resolution.key); + if (!ruleCheck + || ruleCheck.token !== retainedCheck.resolution.token + || ruleCheck.status === 'pending' + || checks.some(check => this.sameRuleCheck(check, retainedCheck))) { + continue; + } + checks.push(retainedCheck); + } + return checks.sort((left, right) => this.checkDisplayOrder(left) - this.checkDisplayOrder(right)); }); readonly allChecksAutomaticFailure = computed(() => { @@ -145,4 +167,18 @@ export class PagePsrWarningPanelComponent { if (this.outcome(check)) return 1; return 0; } + + private retainResolvedRuleCheck(check: PSRCheck): void { + if (!check.resolution) return; + this.retainedResolvedRuleChecks.update(current => + current.some(existing => this.sameRuleCheck(existing, check)) + ? current + : [...current, check] + ); + } + + private sameRuleCheck(left: PSRCheck, right: PSRCheck): boolean { + return left.resolution?.key === right.resolution?.key + && left.resolution?.token === right.resolution?.token; + } } diff --git a/src/app/components/page-viewer/svg-interaction.service.spec.ts b/src/app/components/page-viewer/svg-interaction.service.spec.ts index 9937166e1..5cb8f5dba 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -24,6 +24,7 @@ import { SvgInteractionService } from './svg-interaction.service'; import type { ZoomPanServiceInterface } from './zoom-pan.interface'; import { PageViewerStateService } from './internal/page-viewer-state.service'; import { CORE_2026_GAME_RULES } from '../../models/rules/game-rules'; +import type { EquipmentAction } from '../../models/cbt-force-unit.model'; type SvgInteractionServicePrivate = { addSvgTapHandler( @@ -64,7 +65,7 @@ function createSvgInteractionUnit(overrides: T): T & { getInve getInventory: () => MountedEquipment[]; getEquipmentStatus: (entry: MountedEquipment) => 'available' | 'disabled' | 'destroyed'; isEquipmentOperational: (entry: MountedEquipment) => boolean; - canPerformEquipmentAction: (entry: MountedEquipment) => boolean; + canPerformEquipmentAction: (entry: MountedEquipment, action?: EquipmentAction) => boolean; rules: typeof NO_CONDITION_RULES; }; return unit; @@ -325,6 +326,50 @@ describe('SvgInteractionService', () => { expect(unit.isInventoryControlEntrySelected(entry.id)).toBeTrue(); }); + it('does not change an alternative mode when the canonical mode action is unavailable', () => { + const { svg, entry, unit } = createInventoryInteractionUnit(` + + + + + + + + + + `, 'ATM'); + const canPerform = spyOn(unit, 'canPerformEquipmentAction') + .and.callFake((_entry: MountedEquipment, action?: EquipmentAction) => action !== 'change-mode'); + service.updateUnit(unit); + service.setupInteractions(svg); + + (entry.el!.querySelector('.alternativeMode[mode="High Explosive"] .alternativeModeButton') as SVGElement) + .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + expect(entry.states.has(INVENTORY_CONTROL_MODE_STATE)).toBeFalse(); + expect(unit.isInventoryControlEntrySelected(entry.id)).toBeFalse(); + expect(canPerform).toHaveBeenCalledWith(entry, 'change-mode'); + }); + + it('does not mutate sheet selection, range, or target when the attack action is unavailable', () => { + const { svg, entry, unit } = createInventoryInteractionUnit(); + unit.createInventoryControlTarget(); + const canPerform = spyOn(unit, 'canPerformEquipmentAction') + .and.callFake((_entry: MountedEquipment, action?: EquipmentAction) => action === 'change-mode'); + service.updateUnit(unit); + service.setupInteractions(svg); + + (entry.el!.querySelector('.mainButton') as SVGElement) + .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + (entry.el!.querySelector('.shrButton') as SVGElement) + .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + expect(unit.isInventoryControlEntrySelected(entry.id)).toBeFalse(); + expect(unit.getInventoryControlEntryRange(entry.id)).toBeUndefined(); + expect(unit.getInventoryControlEntryTargetId(entry.id)).toBeUndefined(); + expect(canPerform).toHaveBeenCalledWith(entry, 'fire'); + }); + it('keeps selected alternative mode entries on when switching to another mode button', () => { const { svg, entry, unit } = createInventoryInteractionUnit(` @@ -389,6 +434,14 @@ describe('SvgInteractionService', () => { unit.getInventory = () => [entry, module]; service.updateUnit(unit); service.setupInteractions(svg); + const canPerform = spyOn(unit, 'canPerformEquipmentAction') + .and.callFake((_entry: MountedEquipment, action?: EquipmentAction) => action !== 'change-mode'); + + (module.el!.querySelector(':scope > .mainButton') as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + expect(entry.states.has(INVENTORY_CONTROL_MODE_STATE)).toBeFalse(); + expect(unit.isInventoryControlEntrySelected(entry.id)).toBeFalse(); + + canPerform.and.returnValue(true); (module.el!.querySelector(':scope > .mainButton') as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(entry.states.get(INVENTORY_CONTROL_MODE_STATE)).toBe(RISC_LASER_PULSE_MODE); @@ -936,6 +989,23 @@ describe('SvgInteractionService', () => { expect(unit.getInventoryControlEntryTargetId(entry.id)).toBe('B'); }); + it('rechecks the attack action before applying a target-picker selection', () => { + const { svg, entry, unit } = createInventoryInteractionUnit(); + unit.createInventoryControlTarget(); + unit.createInventoryControlTarget(); + const canPerform = spyOn(unit, 'canPerformEquipmentAction').and.returnValue(true); + service.updateUnit(unit); + service.setupInteractions(svg); + + (entry.el!.querySelector('.shrButton') as SVGElement) + .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + canPerform.and.returnValue(false); + (document.body.querySelector('.weapon-target-choice-menu .target-choice:not(.empty-choice)') as HTMLButtonElement).click(); + + expect(unit.getInventoryControlEntryTargetId(entry.id)).toBeUndefined(); + expect(canPerform).toHaveBeenCalledWith(entry, 'fire'); + }); + it('uses typed hit modifiers instead of rendered SVG hit text in the target picker fallback', () => { const { svg, entry, unit } = createInventoryInteractionUnit(` diff --git a/src/app/components/page-viewer/svg-interaction.service.ts b/src/app/components/page-viewer/svg-interaction.service.ts index 23e0196fc..50854c052 100644 --- a/src/app/components/page-viewer/svg-interaction.service.ts +++ b/src/app/components/page-viewer/svg-interaction.service.ts @@ -29,7 +29,7 @@ import { type ChoicePickerStyle, PickerFactoryService } from '../../services/pic import { EquipmentDialogComponent } from '../equipment-dialog/equipment-dialog.component'; import type { EquipmentDialogContext, EquipmentDialogData, EquipmentDialogTab } from '../equipment-dialog/equipment-dialog.model'; import { WeaponTargetChoiceMenuComponent } from '../../components/equipment-dialog/weapon-target-choice-menu.component'; -import { getInventoryControlGroups, getInventoryControlModeAmmoSummary, getInventoryControlModes, getSelectedInventoryControlMode, INVENTORY_CONTROL_MODE_STATE, resolveInventoryControlSelectedAmmoOption, selectInventoryControlEntry, setInventoryControlMode, syncSvgMode, type InventoryRangeKey } from '../../utils/inventory-control.util'; +import { getInventoryControlGroups, getInventoryControlModeAmmoSummary, getInventoryControlModes, getSelectedInventoryControlMode, inventoryControlEntryAction, INVENTORY_CONTROL_MODE_STATE, resolveInventoryControlSelectedAmmoOption, selectInventoryControlEntry, setInventoryControlMode, syncSvgMode, type InventoryRangeKey } from '../../utils/inventory-control.util'; import type { InventoryControlRuntimeTarget, InventoryControlRuntimeTargetId } from '../../models/inventory-control-runtime-state.model'; import { inventoryTargetCategory, inventoryTargetNumberText, inventoryTargetRangeSelection } from '../../utils/inventory-target-number.util'; import { CORE_2026_GAME_RULES } from '../../models/rules/game-rules'; @@ -1130,22 +1130,14 @@ export class SvgInteractionService { const unit = this.unit(); if (!unit) return; - const clickedMode = this.validInventoryModeForButton(entry, button); - const selectedMode = this.selectedInventoryControlMode(entry); - const forceSelected = !!clickedMode && clickedMode !== selectedMode; - if (clickedMode) { - setInventoryControlMode(entry, clickedMode); - } + const forceSelected = this.applyInventoryModeFromButton(entry, button); + if (forceSelected === null) return; const updated = selectInventoryControlEntry(unit, entry, (selectedTargetId, targets) => { this.showInventoryTargetPicker(entry, button, selectedTargetId, targets); }, forceSelected); if (updated) { this.removePicker(); - } else if (button.classList.contains('mainButton') && entry.el?.classList.contains('bay')) { - // This is a poorly designed workaround to allow toggling the bay entry selection. - unit.setInventoryControlEntrySelected(entry, forceSelected || !unit.isInventoryControlEntrySelected(entry.id)); - this.removePicker(); } }; @@ -1157,13 +1149,9 @@ export class SvgInteractionService { && unit.getUnit().type !== 'Aero' && !unit.allowsExtremeRangeAttacks()) return; - const clickedMode = this.validInventoryModeForButton(entry, button); - const selectedMode = this.selectedInventoryControlMode(entry); - const forceSelected = !!clickedMode && clickedMode !== selectedMode; - - if (clickedMode) { - setInventoryControlMode(entry, clickedMode); - } + const forceSelected = this.applyInventoryModeFromButton(entry, button); + if (forceSelected === null + || !entry.owner.canPerformEquipmentAction(entry, inventoryControlEntryAction(entry))) return; const targets = unit.getInventoryControlTargets(); if (targets.length === 0) { unit.toggleInventoryControlEntryRange(entry, range, forceSelected); @@ -1258,15 +1246,30 @@ export class SvgInteractionService { ?? getSelectedInventoryControlMode(entry, this.dataService.getEquipmentRegistry(), rules.matchesAmmo); } + /** Apply a clicked alternative mode only when the canonical mode action permits it. */ + private applyInventoryModeFromButton(entry: MountedEquipment, button: SVGElement): boolean | null { + const clickedMode = this.validInventoryModeForButton(entry, button); + if (!clickedMode || clickedMode === this.selectedInventoryControlMode(entry)) return false; + if (!entry.owner.canPerformEquipmentAction(entry, 'change-mode')) return null; + setInventoryControlMode(entry, clickedMode); + return true; + } + private toggleRiscLaserPulseMode(module: MountedEquipment): void { const unit = this.unit(); const parent = module.parent; if (!unit || !parent || !isLaserWithRiscModule(parent)) return; + if (unit.readOnly() + || unit.getEquipmentStatus(parent) !== 'available' + || unit.getEquipmentStatus(module) !== 'available' + || !parent.owner.canPerformEquipmentAction(parent, 'change-mode')) return; const mode = selectedRiscLaserMode(parent) === RISC_LASER_PULSE_MODE ? RISC_LASER_STANDARD_MODE : RISC_LASER_PULSE_MODE; setInventoryControlMode(parent, mode); - unit.setInventoryControlEntrySelected(parent, true); + if (parent.owner.canPerformEquipmentAction(parent, inventoryControlEntryAction(parent))) { + unit.setInventoryControlEntrySelected(parent, true); + } this.removePicker(); } @@ -1307,6 +1310,10 @@ export class SvgInteractionService { componentRef.changeDetectorRef.detectChanges(); outputToObservable(componentRef.instance.selected).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(targetId => { + if (!entry.owner.canPerformEquipmentAction(entry, inventoryControlEntryAction(entry))) { + this.overlayManager.closeManagedOverlay(SVG_INVENTORY_TARGET_CHOICE_OVERLAY_KEY); + return; + } unit.setInventoryControlEntryTarget(entry, targetId); this.overlayManager.closeManagedOverlay(SVG_INVENTORY_TARGET_CHOICE_OVERLAY_KEY); }); diff --git a/src/app/equipment-handlers/base/cycle-mode.handler.ts b/src/app/equipment-handlers/base/cycle-mode.handler.ts index 1610dcc81..b87421068 100644 --- a/src/app/equipment-handlers/base/cycle-mode.handler.ts +++ b/src/app/equipment-handlers/base/cycle-mode.handler.ts @@ -23,8 +23,9 @@ export abstract class CycleModeHandler extends EquipmentInteractionHandler { } handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): boolean { - equipment.states?.set(this.stateKey, String(choice.value)); - equipment.owner.setInventoryEntry(equipment); + if (equipment.setState(this.stateKey, String(choice.value))) { + equipment.owner.setInventoryEntry(equipment); + } context.toastService.showToast( `${equipment.equipment?.name||equipment.name} changed ${this.modeLabel.toLowerCase()}: ${choice.label}`, diff --git a/src/app/equipment-handlers/base/multi-mode.handler.spec.ts b/src/app/equipment-handlers/base/multi-mode.handler.spec.ts index c0d4cd2c4..591821471 100644 --- a/src/app/equipment-handlers/base/multi-mode.handler.spec.ts +++ b/src/app/equipment-handlers/base/multi-mode.handler.spec.ts @@ -11,6 +11,7 @@ import { createHandlerQueryContext, } from '../../services/equipment-interaction-registry.service'; import type { ToastService } from '../../services/toast.service'; +import { createTestEquipmentOwner } from '../../testing/unit-test-helpers'; import { MultiModeHandler } from './multi-mode.handler'; class TestMultiModeHandler extends MultiModeHandler { @@ -30,9 +31,8 @@ class TestMultiModeHandler extends MultiModeHandler { describe('MultiModeHandler', () => { it('persists the selected mode value and round-trips it through the choices', () => { - const owner = { - setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - } as never; + const { owner } = createTestEquipmentOwner(); + spyOn(owner, 'setInventoryEntry').and.callThrough(); const equipment = new MountedEquipment({ owner, id: 'test-equipment', diff --git a/src/app/equipment-handlers/base/multi-mode.handler.ts b/src/app/equipment-handlers/base/multi-mode.handler.ts index 61993b1e8..573a0014a 100644 --- a/src/app/equipment-handlers/base/multi-mode.handler.ts +++ b/src/app/equipment-handlers/base/multi-mode.handler.ts @@ -25,8 +25,9 @@ export abstract class MultiModeHandler extends EquipmentInteractionHandler { } handleSelection(equipment: MountedEquipment, value: PickerChoice, context: HandlerCommandContext): boolean { - equipment.states?.set(this.stateKey, String(value.value)); - equipment.owner.setInventoryEntry(equipment); + if (equipment.setState(this.stateKey, String(value.value))) { + equipment.owner.setInventoryEntry(equipment); + } const mode = this.getModes(equipment).find(m => m.value === value.value); context.toastService.showToast( diff --git a/src/app/equipment-handlers/base/toggle.handler.ts b/src/app/equipment-handlers/base/toggle.handler.ts index c719b8fb2..f1e1be301 100644 --- a/src/app/equipment-handlers/base/toggle.handler.ts +++ b/src/app/equipment-handlers/base/toggle.handler.ts @@ -31,8 +31,9 @@ export abstract class ToggleHandler extends EquipmentInteractionHandler { handleSelection(equipment: MountedEquipment, value: PickerChoice, context: HandlerCommandContext): boolean { const newState = value.value === 'enabled' ? 'enabled' : 'disabled'; - equipment.states?.set(this.stateKey, newState); - equipment.owner.setInventoryEntry(equipment); + if (equipment.setState(this.stateKey, newState)) { + equipment.owner.setInventoryEntry(equipment); + } context.toastService.showToast( `${equipment.equipment?.name||equipment.name} is ${newState === 'enabled' ? this.enabledToastVerb : this.disabledToastVerb}`, 'info' diff --git a/src/app/equipment-handlers/bombast-laser.handler.spec.ts b/src/app/equipment-handlers/bombast-laser.handler.spec.ts index 9102de87a..c116e8f18 100644 --- a/src/app/equipment-handlers/bombast-laser.handler.spec.ts +++ b/src/app/equipment-handlers/bombast-laser.handler.spec.ts @@ -16,6 +16,7 @@ import { type HandlerCommandContext, } from '../services/equipment-interaction-registry.service'; import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { BOMBAST_LASER_CHARGED_COLOR, @@ -33,14 +34,9 @@ import { } from './bombast-laser.handler'; function owner(gameRules: CBTGameRules = CORE_2026_GAME_RULES) { - return { - gameRules, - readOnly: () => false, - setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - canPerformEquipmentAction: (entry: MountedEquipment) => !entry.committedDestroyed(), - getEquipmentStatus: (entry: MountedEquipment) => entry.committedDestroyed() ? 'destroyed' : 'available', - isEquipmentOperational: (entry: MountedEquipment) => !entry.committedDestroyed(), - } as never; + const { owner } = createTestEquipmentOwner({ gameRules }); + spyOn(owner, 'setInventoryEntry').and.callThrough(); + return owner; } function bombastLaser( @@ -315,6 +311,20 @@ describe('BombastLaserHandler', () => { expect(entry.owner.setInventoryEntry).toHaveBeenCalledWith(entry); }); + it('clears a charging laser before pending direct destruction commits', () => { + const entry = bombastLaser(CORE_2026_GAME_RULES, new Map([ + [BOMBAST_LASER_CHARGE_STATE_KEY, BOMBAST_LASER_CHARGING_STATE] + ])); + entry.setPendingDestroyed(true); + + handler.onEndTurn(entry); + + expect(entry.states.has(BOMBAST_LASER_CHARGE_STATE_KEY)).toBeFalse(); + expect(entry.committedDestroyed()).toBeFalse(); + expect(entry.pendingDestroyed()).toBeTrue(); + expect(entry.owner.setInventoryEntry).toHaveBeenCalledWith(entry); + }); + it('does not register any Bombast interaction under Total Warfare', () => { const entry = bombastLaser(TW_GAME_RULES, new Map([ [INVENTORY_CONTROL_MODE_STATE, BOMBAST_LASER_DAMAGE_16_MODE], diff --git a/src/app/equipment-handlers/bombast-laser.handler.ts b/src/app/equipment-handlers/bombast-laser.handler.ts index de4e5ade5..c42a74a14 100644 --- a/src/app/equipment-handlers/bombast-laser.handler.ts +++ b/src/app/equipment-handlers/bombast-laser.handler.ts @@ -5,6 +5,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import type { EquipmentFlag } from '../models/equipment-flags.type'; import { WeaponEquipment, type WeaponDamage } from '../models/equipment.model'; +import type { CriticalSlot } from '../models/force-serialization'; import type { WeaponType } from '../models/weapon-types.model'; import type { MountedEquipment } from '../models/mounted-equipment.model'; import type { ToHitAdjustment } from '../models/rules/game-rules'; @@ -135,7 +136,8 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { if (!supportsBombastLaserRules(equipment)) return; let changed = equipment.deleteState(BOMBAST_LASER_FIRED_STATE_KEY); const state = bombastLaserChargeState(equipment); - if (!equipment.owner.isEquipmentOperational(equipment) && state !== null) { + if (state !== null + && (hasPendingDestruction(equipment) || !equipment.owner.isEquipmentOperational(equipment))) { changed = setBombastLaserChargeState(equipment, null) || changed; } else if (state === BOMBAST_LASER_CHARGING_STATE) { changed = setBombastLaserChargeState(equipment, BOMBAST_LASER_CHARGED_STATE) || changed; @@ -229,6 +231,17 @@ function supportsBombastLaserRules(equipment: MountedEquipment): boolean { return equipment.owner.gameRules.supportsBombastLaserRules; } +function hasPendingDestruction(equipment: MountedEquipment): boolean { + const criticalSlots = currentCriticalSlots(equipment); + return criticalSlots.length > 0 + ? criticalSlots.some(slot => !!slot.destroying && !slot.destroyed) + : equipment.isDestroying(); +} + +function currentCriticalSlots(equipment: MountedEquipment): CriticalSlot[] { + return equipment.critSlots?.flatMap(slot => equipment.owner.findCurrentCriticalSlot(slot) ?? []) ?? []; +} + function setBombastLaserChargeState(equipment: MountedEquipment, state: BombastLaserChargeState | null): boolean { return state === null ? equipment.deleteState(BOMBAST_LASER_CHARGE_STATE_KEY) diff --git a/src/app/equipment-handlers/disabled-equipment.handler.spec.ts b/src/app/equipment-handlers/disabled-equipment.handler.spec.ts index e75ae8544..7f63b4409 100644 --- a/src/app/equipment-handlers/disabled-equipment.handler.spec.ts +++ b/src/app/equipment-handlers/disabled-equipment.handler.spec.ts @@ -14,27 +14,13 @@ import { } from '../services/equipment-interaction-registry.service'; import type { DialogsService } from '../services/dialogs.service'; import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; import { DisabledEquipmentHandler, isEquipmentDisabledByFailure } from './disabled-equipment.handler'; function owner() { - const getEquipmentStatus = (entry: MountedEquipment) => ( - entry.committedDestroyed() - ? 'destroyed' - : isEquipmentDisabledByFailure(entry) - ? 'disabled' - : 'available' - ); - return { - readOnly: () => false, - setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - getEquipmentStatus, - isEquipmentOperational: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', - canPerformEquipmentAction: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', - canEditEquipmentState: (entry: MountedEquipment, edit: string) => { - const status = getEquipmentStatus(entry); - return edit === 'enable' ? status === 'disabled' : status === 'available'; - }, - } as never; + const { owner } = createTestEquipmentOwner(); + spyOn(owner, 'setInventoryEntry').and.callThrough(); + return owner; } function entry(flags: EquipmentFlag[], states = new Map(), destroyed = false): MountedEquipment { diff --git a/src/app/equipment-handlers/ecm.handler.ts b/src/app/equipment-handlers/ecm.handler.ts index f6352fae7..8f531b657 100644 --- a/src/app/equipment-handlers/ecm.handler.ts +++ b/src/app/equipment-handlers/ecm.handler.ts @@ -61,8 +61,9 @@ export class ECMHandler extends EquipmentInteractionHandler { } handleSelection(equipment: MountedEquipment, choice: PickerChoice, context: HandlerCommandContext): boolean { - equipment.states?.set(this.stateKey, String(choice.value)); - equipment.owner.setInventoryEntry(equipment); + if (equipment.setState(this.stateKey, String(choice.value))) { + equipment.owner.setInventoryEntry(equipment); + } context.toastService.showToast( `${equipment.equipment?.name||equipment.name} mode: ${choice.label}`, 'info' diff --git a/src/app/equipment-handlers/laser-insulator.handler.spec.ts b/src/app/equipment-handlers/laser-insulator.handler.spec.ts index c69f083f8..3ae1b8b98 100644 --- a/src/app/equipment-handlers/laser-insulator.handler.spec.ts +++ b/src/app/equipment-handlers/laser-insulator.handler.spec.ts @@ -6,33 +6,27 @@ import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; import { LaserInsulatorHandler } from './laser-insulator.handler'; -function owner(unavailableEntry?: MountedEquipment) { - return { - getEquipmentStatus: (candidate: MountedEquipment) => ( - candidate === unavailableEntry || candidate.committedDestroyed() ? 'destroyed' : 'available' - ), - isEquipmentOperational: (candidate: MountedEquipment) => candidate !== unavailableEntry && !candidate.committedDestroyed(), - } as never; -} - -function laser(insulator: MountedEquipment): MountedEquipment { - return new MountedEquipment({ owner: owner(), id: 'laser', name: 'Laser', equipment: new WeaponEquipment({ id: 'laser', name: 'Laser', type: 'weapon', flags: ['F_ENERGY', 'F_LASER'], weapon: { ammoType: 'NA', heat: 3 } }), linkedWith: [insulator] }); -} - -function insulator(unavailable = false): MountedEquipment { - return new MountedEquipment({ - owner: { - getEquipmentStatus: (candidate: MountedEquipment) => ( - unavailable || candidate.committedDestroyed() ? 'destroyed' : 'available' - ), - isEquipmentOperational: (candidate: MountedEquipment) => !unavailable && !candidate.committedDestroyed(), - } as never, +function fixture(insulatorDestroyed = false) { + const ownerFixture = createTestEquipmentOwner(); + const linked = new MountedEquipment({ + owner: ownerFixture.owner, id: 'insulator', name: 'Laser Insulator', + destroyed: insulatorDestroyed, equipment: new MiscEquipment({ id: 'insulator', name: 'Laser Insulator', type: 'misc', flags: ['F_WEAPON_ENHANCEMENT', 'F_LASER_INSULATOR'] }) }); + const parent = new MountedEquipment({ + owner: ownerFixture.owner, + id: 'laser', + name: 'Laser', + equipment: new WeaponEquipment({ id: 'laser', name: 'Laser', type: 'weapon', flags: ['F_ENERGY', 'F_LASER'], weapon: { ammoType: 'NA', heat: 3 } }), + linkedWith: [linked], + }); + ownerFixture.inventory.push(parent, linked); + return { linked, parent }; } describe('LaserInsulatorHandler', () => { @@ -40,32 +34,31 @@ describe('LaserInsulatorHandler', () => { const context = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); it('reduces model heat while the insulator is available', () => { - const linked = insulator(); + const { linked, parent } = fixture(); - expect(handler.applyLinkedInventoryControlHeatEffects(linked, laser(linked), { value: 3, weakened: false }, context)) + expect(handler.applyLinkedInventoryControlHeatEffects(linked, parent, { value: 3, weakened: false }, context)) .toEqual({ value: 2, weakened: false, suffix: '*' }); }); it('does not reduce heat when the linked insulator is unavailable', () => { - const linked = insulator(true); + const { linked, parent } = fixture(true); - expect(handler.applyLinkedInventoryControlHeatEffects(linked, laser(linked), { value: 3, weakened: false }, context)) + expect(handler.applyLinkedInventoryControlHeatEffects(linked, parent, { value: 3, weakened: false }, context)) .toEqual({ value: 3, weakened: true }); }); it('does not reduce heat below one', () => { - const linked = insulator(); + const { linked, parent } = fixture(); - expect(handler.applyLinkedInventoryControlHeatEffects(linked, laser(linked), { value: 1, weakened: false }, context)) + expect(handler.applyLinkedInventoryControlHeatEffects(linked, parent, { value: 1, weakened: false }, context)) .toEqual({ value: 1, weakened: false, suffix: '*' }); }); it('does not affect non-laser weapons', () => { - const linked = insulator(); - const weapon = laser(linked); - weapon.equipment!.flags.delete('F_LASER'); + const { linked, parent } = fixture(); + parent.equipment!.flags.delete('F_LASER'); - expect(handler.applyLinkedInventoryControlHeatEffects(linked, weapon, { value: 3, weakened: false }, context)) + expect(handler.applyLinkedInventoryControlHeatEffects(linked, parent, { value: 3, weakened: false }, context)) .toEqual({ value: 3, weakened: false }); }); }); diff --git a/src/app/equipment-handlers/masc.handler.spec.ts b/src/app/equipment-handlers/masc.handler.spec.ts index debd14373..598d4762c 100644 --- a/src/app/equipment-handlers/masc.handler.spec.ts +++ b/src/app/equipment-handlers/masc.handler.spec.ts @@ -2,14 +2,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import type { EquipmentStateEdit } from '../models/cbt-force-unit.model'; import { EquipmentFlag } from '../models/equipment-flags.type'; import { MiscEquipment } from '../models/equipment.model'; import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; -import type { EquipmentStatus } from '../models/equipment-status.model'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; -import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../models/rules/unit-type-rules'; +import { ENTRY_DISABLED_STATE_KEY } from '../models/rules/unit-type-rules'; import type { DialogsService } from '../services/dialogs.service'; import { createHandlerCommandContext, @@ -18,6 +16,7 @@ import { type HandlerQueryContext, } from '../services/equipment-interaction-registry.service'; import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; import { MASC_ACTIVE_STATE_KEY, MASC_SEQUENCE_STATE_KEY, @@ -33,25 +32,13 @@ function owner( airborne: () => airborne, ...turnStateOverrides, }; - const getEquipmentStatus = (entry: MountedEquipment): EquipmentStatus => entry.committedDestroyed() - ? 'destroyed' - : entry.states.get(ENTRY_DISABLED_STATE_KEY) === ENTRY_DISABLED_STATE_VALUE - ? 'disabled' - : 'available'; - return { - readOnly: () => false, - getEquipmentStatus, - isEquipmentOperational: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', - canPerformEquipmentAction: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', - canEditEquipmentState: (entry: MountedEquipment, edit: EquipmentStateEdit) => { - const status = getEquipmentStatus(entry); - return edit === 'enable' ? status === 'disabled' : status === 'available'; - }, - gameRules, + const { owner } = createTestEquipmentOwner({ gameRules }); + Object.assign(owner, { getNotificationDisplayName: () => 'Atlas AS7-D (Natasha Kerensky)', - setInventoryEntry: jasmine.createSpy('setInventoryEntry'), turnState: () => turnState, - } as never; + }); + spyOn(owner, 'setInventoryEntry').and.callThrough(); + return owner; } function mascEntry( diff --git a/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts b/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts index ac7e2ecd9..6bc7d8246 100644 --- a/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts +++ b/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts @@ -6,7 +6,6 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; import { EMPTY_EQUIPMENT_REGISTRY, EquipmentRegistry } from '../models/equipment-lookup'; import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; -import type { CBTForceUnit } from '../models/cbt-force-unit.model'; import type { CriticalSlot } from '../models/force-serialization'; import type { DialogsService } from '../services/dialogs.service'; import { @@ -15,6 +14,7 @@ import { EquipmentInteractionRegistry, } from '../services/equipment-interaction-registry.service'; import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; import { resolveInventoryControlDamageText } from '../utils/inventory-control-damage.util'; import { PPC_CAPACITOR_CHARGING_STATE, @@ -25,12 +25,8 @@ import { } from './ppc-capacitor.handler'; function setup(destroyed = false, compatible = true) { - const owner = { - setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - getEquipmentStatus: (entry: MountedEquipment) => entry.committedDestroyed() ? 'destroyed' : 'available', - isEquipmentOperational: (entry: MountedEquipment) => !entry.committedDestroyed(), - canPerformEquipmentAction: (entry: MountedEquipment) => !entry.committedDestroyed(), - } as unknown as CBTForceUnit; + const fixture = createTestEquipmentOwner(); + const { owner } = fixture; const capacitor = new MountedEquipment({ owner, id: 'capacitor', @@ -59,7 +55,8 @@ function setup(destroyed = false, compatible = true) { }), linkedWith: [capacitor] }); - return { owner, weapon, capacitor }; + fixture.inventory.push(weapon, capacitor); + return { ...fixture, weapon, capacitor }; } function setupWithCriticalSlots() { @@ -78,15 +75,10 @@ function setupWithCriticalSlots() { loc: 'LA', slot: 1, }; - const currentSlots = [...weaponSlots, ...capacitorSlots, unrelatedSlot]; + const currentSlots = fixture.criticalSlots; + currentSlots.push(...weaponSlots, ...capacitorSlots, unrelatedSlot); fixture.weapon.critSlots = weaponSlots.map(slot => ({ ...slot })); fixture.capacitor.critSlots = capacitorSlots.map(slot => ({ ...slot })); - Object.assign(fixture.owner, { - getCritSlots: () => currentSlots, - findCurrentCriticalSlot: (snapshot: CriticalSlot) => currentSlots.find(slot => - slot.loc === snapshot.loc && slot.slot === snapshot.slot) ?? null, - setCritSlots: jasmine.createSpy('setCritSlots'), - }); return { ...fixture, weaponSlots, capacitorSlots, unrelatedSlot }; } @@ -221,25 +213,25 @@ describe('PpcCapacitorHandler', () => { }); it('discharges and marks the capacitor fired after firing', () => { - const { weapon, capacitor, owner } = setup(); + const { weapon, capacitor, inventoryWrites } = setup(); capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); handler.afterInventoryControlFire(weapon); expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); expect(capacitor.states.get(PPC_CAPACITOR_FIRED_STATE_KEY)).toBe('1'); - expect(owner.setInventoryEntry).toHaveBeenCalledWith(capacitor); + expect(inventoryWrites).toEqual([capacitor]); }); it('discharges an unavailable capacitor after its linked PPC fires', () => { - const { weapon, capacitor, owner } = setup(true); + const { weapon, capacitor, inventoryWrites } = setup(true); capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); handler.afterInventoryControlFire(weapon); expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); expect(capacitor.states.get(PPC_CAPACITOR_FIRED_STATE_KEY)).toBe('1'); - expect(owner.setInventoryEntry).toHaveBeenCalledWith(capacitor); + expect(inventoryWrites).toEqual([capacitor]); }); for (const state of [PPC_CAPACITOR_CHARGING_STATE, PPC_CAPACITOR_CHARGED_STATE] as const) { @@ -264,7 +256,7 @@ describe('PpcCapacitorHandler', () => { } it('does not explode direct-inventory mounts before a charged hit is pending', () => { - const { weapon, capacitor, owner } = setup(); + const { weapon, capacitor, inventoryWrites } = setup(); capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); handler.beforeEquipmentStateCommit(weapon); @@ -272,7 +264,7 @@ describe('PpcCapacitorHandler', () => { expect(weapon.hasPendingDestroyedChange()).toBeFalse(); expect(capacitor.hasPendingDestroyedChange()).toBeFalse(); expect(capacitor.states.get(PPC_CAPACITOR_STATE_KEY)).toBe(PPC_CAPACITOR_CHARGED_STATE); - expect(owner.setInventoryEntry).not.toHaveBeenCalled(); + expect(inventoryWrites).toEqual([]); }); it('commits an ordinary direct-inventory hit while the capacitor is discharged', () => { @@ -291,7 +283,7 @@ describe('PpcCapacitorHandler', () => { for (const state of [PPC_CAPACITOR_CHARGING_STATE, PPC_CAPACITOR_CHARGED_STATE] as const) { for (const hitEntry of ['PPC', 'capacitor'] as const) { it(`destroys every linked Mek critical slot when a ${state} ${hitEntry} slot hit is committed`, () => { - const { weapon, capacitor, owner, weaponSlots, capacitorSlots, unrelatedSlot } = setupWithCriticalSlots(); + const { weapon, capacitor, criticalSlotWrites, weaponSlots, capacitorSlots, unrelatedSlot } = setupWithCriticalSlots(); capacitor.states.set(PPC_CAPACITOR_STATE_KEY, state); const hitSlots = hitEntry === 'PPC' ? weaponSlots : capacitorSlots; hitSlots[0].hits = 1; @@ -307,7 +299,7 @@ describe('PpcCapacitorHandler', () => { expect(weapon.hasPendingDestroyedChange()).toBeFalse(); expect(capacitor.hasPendingDestroyedChange()).toBeFalse(); expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); - expect(owner.setCritSlots).toHaveBeenCalledTimes(1); + expect(criticalSlotWrites.length).toBe(1); }); } } @@ -323,11 +315,11 @@ describe('PpcCapacitorHandler', () => { expect(committed.weaponSlots[1].destroying).toBeUndefined(); expect(committed.capacitorSlots.every(slot => slot.destroying === undefined)).toBeTrue(); - expect(committed.owner.setCritSlots).not.toHaveBeenCalled(); + expect(committed.criticalSlotWrites).toEqual([]); }); it('does not treat location-derived critical destruction as a PPC critical hit', () => { - const { weapon, capacitor, owner, weaponSlots, capacitorSlots } = setupWithCriticalSlots(); + const { weapon, capacitor, criticalSlotWrites, weaponSlots, capacitorSlots } = setupWithCriticalSlots(); capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); weaponSlots[0].destroying = 10; @@ -337,7 +329,7 @@ describe('PpcCapacitorHandler', () => { expect(weaponSlots[1].destroying).toBeUndefined(); expect(capacitorSlots.every(slot => slot.destroying === undefined)).toBeTrue(); expect(capacitor.states.get(PPC_CAPACITOR_STATE_KEY)).toBe(PPC_CAPACITOR_CHARGED_STATE); - expect(owner.setCritSlots).not.toHaveBeenCalled(); + expect(criticalSlotWrites).toEqual([]); handler.onEndTurn(weapon); @@ -389,7 +381,7 @@ describe('PpcCapacitorHandler', () => { }); it('does not let an unavailable charging capacitor block its usable PPC', () => { - const { weapon, capacitor, owner } = setup(true); + const { weapon, capacitor, inventoryWrites } = setup(true); capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGING_STATE); expect(handler.isInventoryControlSelectable(weapon, queryContext)).toBeNull(); @@ -397,7 +389,7 @@ describe('PpcCapacitorHandler', () => { handler.onEndTurn(weapon); expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); - expect(owner.setInventoryEntry).toHaveBeenCalledWith(capacitor); + expect(inventoryWrites).toEqual([capacitor]); }); it('rejects charging after the linked PPC fired this turn', () => { diff --git a/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts b/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts index be3605b3b..8e0e69fef 100644 --- a/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts +++ b/src/app/equipment-handlers/risc-laser-pulse-module.handler.spec.ts @@ -6,47 +6,37 @@ import { MiscEquipment, WeaponEquipment } from '../models/equipment.model'; import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; import { INVENTORY_CONTROL_MODE_STATE } from '../utils/inventory-control.util'; import { RISC_LASER_PULSE_MODE, RISC_LASER_STANDARD_MODE, RiscLaserPulseModuleHandler } from './risc-laser-pulse-module.handler'; -function owner() { - return { - setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - getEquipmentStatus: (entry: MountedEquipment) => entry.committedDestroyed() ? 'destroyed' : 'available', - isEquipmentOperational: (entry: MountedEquipment) => !entry.committedDestroyed(), - canPerformEquipmentAction: (entry: MountedEquipment) => !entry.committedDestroyed(), - } as never; -} - -function laser(module: MountedEquipment, states = new Map()): MountedEquipment { +function fixture(moduleDestroyed = false, states = new Map()) { + const ownerFixture = createTestEquipmentOwner(); + const { owner } = ownerFixture; + const linked = new MountedEquipment({ + owner, + id: 'risc', + name: 'RISC Laser Pulse Module', + destroyed: moduleDestroyed, + equipment: new MiscEquipment({ id: 'risc', name: 'RISC Laser Pulse Module', type: 'misc', flags: ['F_WEAPON_ENHANCEMENT', 'F_RISC_LASER_PULSE_MODULE'] }) + }); const entry = new MountedEquipment({ - owner: owner(), + owner, id: 'laser', name: 'Medium Laser', states, equipment: new WeaponEquipment({ id: 'laser', name: 'Medium Laser', type: 'weapon', flags: ['F_ENERGY', 'F_LASER'], weapon: { ammoType: 'NA', heat: 3 } }), - linkedWith: [module] - }); - module.parent = entry; - return entry; -} - -function module(destroyed = false): MountedEquipment { - return new MountedEquipment({ - owner: owner(), - id: 'risc', - name: 'RISC Laser Pulse Module', - destroyed, - equipment: new MiscEquipment({ id: 'risc', name: 'RISC Laser Pulse Module', type: 'misc', flags: ['F_WEAPON_ENHANCEMENT', 'F_RISC_LASER_PULSE_MODULE'] }) + linkedWith: [linked] }); + ownerFixture.inventory.push(entry, linked); + return { entry, linked }; } describe('RiscLaserPulseModuleHandler', () => { const handler = new RiscLaserPulseModuleHandler(); const context = createHandlerQueryContext(EMPTY_EQUIPMENT_REGISTRY); it('offers STD and PULSE modes from the linked laser row', () => { - const linked = module(); - const entry = laser(linked); + const { entry } = fixture(); const choice = handler.getChoices(entry, context)[0]; @@ -59,8 +49,7 @@ describe('RiscLaserPulseModuleHandler', () => { }); it('adds pulse heat and linked hit modifier only in pulse mode', () => { - const linked = module(); - const entry = laser(linked, new Map([[INVENTORY_CONTROL_MODE_STATE, RISC_LASER_PULSE_MODE]])); + const { linked, entry } = fixture(false, new Map([[INVENTORY_CONTROL_MODE_STATE, RISC_LASER_PULSE_MODE]])); expect(handler.applyInventoryControlHeatEffects(entry, { value: 3, weakened: false }, context)) .toEqual({ value: 5, weakened: false }); @@ -77,8 +66,7 @@ describe('RiscLaserPulseModuleHandler', () => { }); it('falls back to STD and allows aimed shots when the module is unavailable', () => { - const linked = module(true); - const entry = laser(linked, new Map([[INVENTORY_CONTROL_MODE_STATE, RISC_LASER_PULSE_MODE]])); + const { linked, entry } = fixture(true, new Map([[INVENTORY_CONTROL_MODE_STATE, RISC_LASER_PULSE_MODE]])); expect(handler.getChoices(entry, context)).toEqual([]); expect(handler.applyInventoryControlHeatEffects(entry, { value: 3, weakened: false }, context)) @@ -90,8 +78,7 @@ describe('RiscLaserPulseModuleHandler', () => { }); it('vetoes aimed shots in pulse mode', () => { - const linked = module(); - const entry = laser(linked, new Map([[INVENTORY_CONTROL_MODE_STATE, RISC_LASER_PULSE_MODE]])); + const { entry } = fixture(false, new Map([[INVENTORY_CONTROL_MODE_STATE, RISC_LASER_PULSE_MODE]])); expect(handler.canPerformAimedShot(entry, context)).toBeFalse(); }); diff --git a/src/app/equipment-handlers/stealth.handler.spec.ts b/src/app/equipment-handlers/stealth.handler.spec.ts index 38ee99891..36b1ab93d 100644 --- a/src/app/equipment-handlers/stealth.handler.spec.ts +++ b/src/app/equipment-handlers/stealth.handler.spec.ts @@ -9,13 +9,12 @@ import { MountedEquipment } from '../models/mounted-equipment.model'; import { createHandlerCommandContext, createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; import type { DialogsService } from '../services/dialogs.service'; import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; import { StealthHandler } from './stealth.handler'; function equipment(flag: 'F_STEALTH' | 'F_CHAMELEON_SHIELD' | 'F_ECM'): MountedEquipment { - const owner = { - setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - isEquipmentOperational: () => true, - } as never; + const { owner } = createTestEquipmentOwner(); + spyOn(owner, 'setInventoryEntry').and.callThrough(); return new MountedEquipment({ owner, id: flag, diff --git a/src/app/equipment-handlers/uacjamming.handler.spec.ts b/src/app/equipment-handlers/uacjamming.handler.spec.ts index 25fd8b22d..c89f34670 100644 --- a/src/app/equipment-handlers/uacjamming.handler.spec.ts +++ b/src/app/equipment-handlers/uacjamming.handler.spec.ts @@ -10,25 +10,13 @@ import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../models/ import { createHandlerCommandContext, createHandlerQueryContext } from '../services/equipment-interaction-registry.service'; import type { DialogsService } from '../services/dialogs.service'; import type { ToastService } from '../services/toast.service'; -import { isEquipmentDisabledByFailure } from './disabled-equipment.handler'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; import { UACJammingHandler } from './uacjamming.handler'; function owner(gameRules: CBTGameRules = CORE_2026_GAME_RULES) { - const getEquipmentStatus = (entry: MountedEquipment) => ( - entry.committedDestroyed() - ? 'destroyed' - : isEquipmentDisabledByFailure(entry) - ? 'disabled' - : 'available' - ); - return { - setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - gameRules, - getEquipmentStatus, - isEquipmentOperational: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', - canPerformEquipmentAction: (entry: MountedEquipment) => getEquipmentStatus(entry) === 'available', - canEditEquipmentState: () => true, - } as never; + const { owner } = createTestEquipmentOwner({ gameRules }); + spyOn(owner, 'setInventoryEntry').and.callThrough(); + return owner; } function weapon(ammoType: AmmoType): WeaponEquipment { diff --git a/src/app/equipment-handlers/vibroblade.handler.spec.ts b/src/app/equipment-handlers/vibroblade.handler.spec.ts index c05cdd657..f6105ddfa 100644 --- a/src/app/equipment-handlers/vibroblade.handler.spec.ts +++ b/src/app/equipment-handlers/vibroblade.handler.spec.ts @@ -5,7 +5,6 @@ import { Equipment, type EquipmentRawData } from '../models/equipment.model'; import { EMPTY_EQUIPMENT_REGISTRY } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; -import type { CBTForceUnit } from '../models/cbt-force-unit.model'; import { createHandlerCommandContext, createHandlerQueryContext, @@ -13,6 +12,7 @@ import { } from '../services/equipment-interaction-registry.service'; import type { DialogsService } from '../services/dialogs.service'; import type { ToastService } from '../services/toast.service'; +import { createTestEquipmentOwner } from '../testing/unit-test-helpers'; import type { InventoryControlDisplayData, InventoryControlDisplayEffectOptions } from '../utils/inventory-control.util'; import { getVibrobladeBaseDamage, VIBROBLADE_MODE_STATE, VIBROBLADE_OFF_MODE, VIBROBLADE_ON_MODE, VibrobladeHandler } from './vibroblade.handler'; @@ -29,14 +29,8 @@ const DISPLAY: InventoryControlDisplayData = { }; function setup(size: 'SMALL' | 'MEDIUM' | 'LARGE' = 'SMALL', destroyed = false, tons = 50) { - const owner = { - readOnly: () => false, - getUnit: () => ({ tons }), - setInventoryEntry: jasmine.createSpy('setInventoryEntry'), - getEquipmentStatus: (entry: MountedEquipment) => entry.committedDestroyed() ? 'destroyed' : 'available', - isEquipmentOperational: (entry: MountedEquipment) => !entry.committedDestroyed(), - canPerformEquipmentAction: (entry: MountedEquipment) => !entry.committedDestroyed(), - } as unknown as CBTForceUnit; + const { owner } = createTestEquipmentOwner({ unit: { tons } }); + spyOn(owner, 'setInventoryEntry').and.callThrough(); const equipment = new Equipment({ id: `${size}Vibroblade`, name: `Vibroblade (${size})`, diff --git a/src/app/equipment-handlers/weapon-ammo.handler.ts b/src/app/equipment-handlers/weapon-ammo.handler.ts index a504f0aad..56c2a4d25 100644 --- a/src/app/equipment-handlers/weapon-ammo.handler.ts +++ b/src/app/equipment-handlers/weapon-ammo.handler.ts @@ -8,7 +8,7 @@ import type { PickerChoice } from '../components/picker/picker.interface'; import { WeaponEquipment } from '../models/equipment.model'; import { EquipmentDialogComponent } from '../components/equipment-dialog/equipment-dialog.component'; import type { EquipmentDialogData } from '../components/equipment-dialog/equipment-dialog.model'; -import { changeAmmoEntryRemaining, getAmmoControlEntriesForWeapon, getAmmoEntryRemaining, setAmmoEntry } from '../utils/ammo-interaction.util'; +import { changeAmmoEntryRemaining, getAmmoControlEntriesForWeapon, getAmmoEntryRemaining, isAmmoControlEntryUsable, setAmmoEntry } from '../utils/ammo-interaction.util'; export class WeaponAmmoHandler extends EquipmentInteractionHandler { readonly id = 'weapon-ammo-handler'; @@ -27,9 +27,9 @@ export class WeaponAmmoHandler extends EquipmentInteractionHandler { const entry = entries[0]; const remaining = getAmmoEntryRemaining(entry); return [ - { label: '-1', value: 'weapon-ammo-decrement', keepOpen: true, disabled: entry.destroyed || remaining <= 0 }, - { label: '+1', value: 'weapon-ammo-increment', keepOpen: true, disabled: entry.destroyed || remaining >= entry.totalAmmo }, - { label: 'Set Ammo', value: 'weapon-ammo-set', disabled: entry.destroyed } + { label: '-1', value: 'weapon-ammo-decrement', keepOpen: true, disabled: !isAmmoControlEntryUsable(entry) || remaining <= 0 }, + { label: '+1', value: 'weapon-ammo-increment', keepOpen: true, disabled: !isAmmoControlEntryUsable(entry) || remaining >= entry.totalAmmo }, + { label: 'Set Ammo', value: 'weapon-ammo-set', disabled: !isAmmoControlEntryUsable(entry) } ]; } diff --git a/src/app/models/cbt-force-unit-state.model.ts b/src/app/models/cbt-force-unit-state.model.ts index 14d5866c2..67fac5468 100644 --- a/src/app/models/cbt-force-unit-state.model.ts +++ b/src/app/models/cbt-force-unit-state.model.ts @@ -149,6 +149,7 @@ export class CBTForceUnitState extends ForceUnitState { this.consolidateInventory(); const turnState = this.turnState(); turnState.resetPSRChecks(); + turnState.commitEquipmentStateChanges(); } private cleanupEndTurnConditions() { diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index ade0b1103..4943343f7 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -25,7 +25,7 @@ import { LaserInsulatorHandler } from '../equipment-handlers/laser-insulator.han import { RISC_LASER_PULSE_MODE, RiscLaserPulseModuleHandler } from '../equipment-handlers/risc-laser-pulse-module.handler'; import { DialogsService } from '../services/dialogs.service'; import { ToastService } from '../services/toast.service'; -import { getInventoryControlAmmoProfileId, getInventoryControlAmmoSelectionOptions, getInventoryControlGroups, INVENTORY_CONTROL_MODE_STATE, syncSvgMode } from '../utils/inventory-control.util'; +import { getInventoryControlAmmoProfileId, getInventoryControlAmmoSelectionOptions, getInventoryControlGroups, getInventoryControlModeAmmoSummary, INVENTORY_CONTROL_MODE_STATE, syncSvgMode } from '../utils/inventory-control.util'; import { AtmHandler } from '../equipment-handlers/atm.handler'; import { MmlHandler } from '../equipment-handlers/mml.handler'; import { ATM_EXTENDED_RANGE_PROFILE, ATM_HIGH_EXPLOSIVE_PROFILE, ATM_STANDARD_PROFILE } from './ammo-weapon-profile.model'; @@ -41,6 +41,11 @@ import { PPC_CAPACITOR_STATE_KEY, PpcCapacitorHandler, } from '../equipment-handlers/ppc-capacitor.handler'; +import { + BOMBAST_LASER_CHARGE_STATE_KEY, + BOMBAST_LASER_CHARGING_STATE, + BombastLaserHandler, +} from '../equipment-handlers/bombast-laser.handler'; function createEquipment(): EquipmentMap { const ultraAc20 = new WeaponEquipment({ @@ -2155,6 +2160,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { id: 'run-movement-bonus-test@CT#0', name: 'Run Movement Bonus Test', equipment: new Equipment({ id: 'run-movement-bonus-test', name: 'Run Movement Bonus Test', type: 'misc', flags: ['F_TEST_ONLY'] }), + locations: new Set(['CT']), }); forceUnit.isLoaded.set(true); entry.setState('active', 'true'); @@ -2168,6 +2174,60 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.turnState().moveDistance()).toBe(8); }); + it('keeps the phase dirty after an equipment state change until phase end', () => { + const forceUnit = createForceUnit(createVehicleUnit(equipment)); + initialize(forceUnit); + const entry = forceUnit.getInventory().find(item => item.equipment instanceof WeaponEquipment)!; + + entry.setState('test-mode', 'charged'); + forceUnit.setInventoryEntry(entry); + + expect(forceUnit.turnState().dirtyPhase()).toBeTrue(); + expect(forceUnit.turnState().serialize()?.equipmentStateChanged).toBeTrue(); + + const restored = CBTForceUnit.deserialize( + forceUnit.serialize(), + new TestCBTForce('Restored Equipment State Force', dataService, unitInitializer, injector), + dataService, + unitInitializer, + injector, + ); + + expect(restored.turnState().dirtyPhase()).toBeTrue(); + + forceUnit.endPhase(); + + expect(forceUnit.turnState().dirtyPhase()).toBeFalse(); + expect(forceUnit.turnState().serialize()?.equipmentStateChanged).toBeUndefined(); + }); + + it('keeps the phase dirty after changing ammo stored in a critical slot', () => { + const forceUnit = createForceUnit(createMekUnit()); + initialize(forceUnit); + const ammo = equipment['Clan Ultra AC/20 Ammo'] as AmmoEquipment; + forceUnit.setCritSlots([{ + id: `${ammo.internalName}@LT#0`, + name: ammo.internalName, + loc: 'LT', + slot: 0, + eq: ammo, + totalAmmo: 5, + consumed: 0, + }], true); + const ammoSlot = forceUnit.getCritSlot('LT', 0)!; + + expect(forceUnit.turnState().dirtyPhase()).toBeFalse(); + + ammoSlot.consumed = 1; + forceUnit.setCritSlot(ammoSlot); + + expect(forceUnit.turnState().dirtyPhase()).toBeTrue(); + + forceUnit.endPhase(); + + expect(forceUnit.turnState().dirtyPhase()).toBeFalse(); + }); + it('splits direct inventory ammo into one entry per bin using q and q2', () => { const forceUnit = createForceUnit(createVehicleUnit(equipment)); @@ -2431,6 +2491,64 @@ describe('CBTForceUnit direct inventory ammo bins', () => { }); } + function installChargingBombast( + forceUnit: CBTForceUnit, + criticalSlots = false, + ): { weapon: MountedWeapon; currentSlots: CriticalSlot[] } { + const location = criticalSlots ? 'RA' : 'FR'; + const weaponId = `TestBombastLaser@${location}#0`; + const bombast = new WeaponEquipment({ + id: 'TestBombastLaser', + name: 'Test Bombast Laser', + type: 'weapon', + flags: ['F_BOMBAST_LASER', 'F_DIRECT_FIRE', 'F_ENERGY', 'F_LASER'], + weapon: { damage: 12, heat: 12, ranges: [5, 10, 15, 20] }, + }); + const currentSlots: CriticalSlot[] = criticalSlots ? [ + { id: weaponId, name: bombast.name, loc: location, slot: 0, eq: bombast }, + { id: weaponId, name: bombast.name, loc: location, slot: 1, eq: bombast }, + ] : []; + if (criticalSlots) forceUnit.setCritSlots(currentSlots, true); + + const weapon = new MountedWeapon({ + owner: forceUnit, + id: weaponId, + name: bombast.name, + equipment: bombast, + locations: new Set([location]), + critSlots: currentSlots.map(slot => ({ ...slot })), + states: new Map([[BOMBAST_LASER_CHARGE_STATE_KEY, BOMBAST_LASER_CHARGING_STATE]]), + }); + forceUnit.setInventory([weapon], true); + TestBed.inject(EquipmentInteractionRegistryService).getRegistry().register(new BombastLaserHandler()); + return { weapon, currentSlots }; + } + + it('clears a charging direct-inventory Bombast Laser before end-turn destruction commits', () => { + const forceUnit = createForceUnit(createVehicleUnit(equipment)); + initialize(forceUnit); + const { weapon } = installChargingBombast(forceUnit); + expect(forceUnit.applyEquipmentDamage(weapon)).toBeTrue(); + + forceUnit.endTurn(); + + const committedWeapon = forceUnit.getInventory().find(entry => entry.id === weapon.id)!; + expect(committedWeapon.committedDestroyed()).toBeTrue(); + expect(committedWeapon.states.has(BOMBAST_LASER_CHARGE_STATE_KEY)).toBeFalse(); + }); + + it('clears a charging Mek Bombast Laser from its current pending critical slot at end turn', () => { + const forceUnit = createForceUnit(createMekUnit()); + const { weapon, currentSlots } = installChargingBombast(forceUnit, true); + forceUnit.applyHitToCritSlot(currentSlots[0]); + + forceUnit.endTurn(); + + const committedWeapon = forceUnit.getInventory().find(entry => entry.id === weapon.id)!; + expect(forceUnit.findCurrentCriticalSlot(currentSlots[0])?.destroyed).toBeTruthy(); + expect(committedWeapon.states.has(BOMBAST_LASER_CHARGE_STATE_KEY)).toBeFalse(); + }); + it('uses the lowest gunnery skill among crew members', () => { const forceUnit = createForceUnit(createEmptyUnit({ name: 'BMTest_MEK-1', @@ -2760,6 +2878,133 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.getInventoryControlSelectedAmmo(weaponEntry)).toBe(precisionAmmo); }); + it('clears a preferred ammo source when pending destruction commits and does not restore it after repair', () => { + const vehicle = createVehicleUnit(equipment); + vehicle.comp[1].q = 1; + vehicle.comp[1].q2 = 5; + const forceUnit = createForceUnit(vehicle); + initialize(forceUnit); + const weapon = forceUnit.getInventory().find(entry => entry.equipment instanceof WeaponEquipment)!; + const ammo = forceUnit.getInventory().find(entry => entry.equipment instanceof AmmoEquipment)!; + const [source] = getInventoryControlAmmoSelectionOptions( + weapon, + forceUnit.getEquipmentRegistry(), + (mountedWeapon, candidate, mode) => forceUnit.matchesInventoryControlAmmo(mountedWeapon, candidate, mode), + ); + forceUnit.setInventoryControlEntryAmmoSelection(weapon.id, { + selectedProfileId: source.profileId, + preferredSourceOptionId: source.id, + }); + + expect(forceUnit.applyEquipmentDamage(ammo)).toBeTrue(); + expect(forceUnit.getInventoryControlEntryAmmoSelection(weapon.id)?.preferredSourceOptionId).toBe(source.id); + + forceUnit.endPhase(); + + expect(ammo.committedDestroyed()).toBeTrue(); + expect(forceUnit.getInventoryControlEntryAmmoSelection(weapon.id)).toEqual({ + selectedProfileId: source.profileId, + preferredSourceOptionId: null, + }); + expect(getInventoryControlAmmoSelectionOptions( + weapon, + forceUnit.getEquipmentRegistry(), + )[0].usable).toBeFalse(); + + expect(forceUnit.repairEquipment(ammo)).toBeTrue(); + forceUnit.endPhase(); + + expect(ammo.committedDestroyed()).toBeFalse(); + expect(forceUnit.getInventoryControlEntryAmmoSelection(weapon.id)).toEqual({ + selectedProfileId: source.profileId, + preferredSourceOptionId: null, + }); + expect(getInventoryControlAmmoSelectionOptions( + weapon, + forceUnit.getEquipmentRegistry(), + )[0].usable).toBeTrue(); + }); + + it('represents ammo in a flooded Mek location as disabled rather than destroyed', () => { + const forceUnit = createForceUnit(createMekUnit()); + initialize(forceUnit, createMekDamageSvg()); + const weaponType = equipment['CLUltraAC20'] as WeaponEquipment; + const ammoType = equipment['Clan Ultra AC/20 Ammo'] as AmmoEquipment; + const ammoSlot: CriticalSlot = { + id: `${ammoType.internalName}@LT#0`, + name: ammoType.internalName, + originalName: ammoType.internalName, + loc: 'LT', + slot: 0, + eq: ammoType, + totalAmmo: 5, + }; + const weapon = new MountedWeapon({ + owner: forceUnit, + id: `${weaponType.internalName}@LA#0`, + name: weaponType.internalName, + equipment: weaponType, + locations: new Set(['LA']), + }); + forceUnit.setCritSlots([ammoSlot], true); + forceUnit.setInventory([weapon], true); + + forceUnit.setLocationCondition('LT', 'flooded', true); + forceUnit.endPhase(); + + const [option] = getInventoryControlModeAmmoSummary( + weapon, + forceUnit.getEquipmentRegistry(), + ).options; + expect(forceUnit.getEquipmentStatus(forceUnit.getCritSlot('LT', 0)!)).toBe('disabled'); + expect(option).toEqual(jasmine.objectContaining({ + remaining: 0, + destroyed: false, + disabled: true, + })); + }); + + it('clears a preferred ammo source immediately when its Mek installation location is committed destroyed', () => { + const forceUnit = createForceUnit(createMekUnit()); + initialize(forceUnit, createMekDamageSvg()); + const weaponType = equipment['CLUltraAC20'] as WeaponEquipment; + const ammoType = equipment['Clan Ultra AC/20 Ammo'] as AmmoEquipment; + const ammoSlot: CriticalSlot = { + id: `${ammoType.internalName}@LT#0`, + name: ammoType.internalName, + originalName: ammoType.internalName, + loc: 'LT', + slot: 0, + eq: ammoType, + totalAmmo: 5, + }; + const weapon = new MountedWeapon({ + owner: forceUnit, + id: `${weaponType.internalName}@LA#0`, + name: weaponType.internalName, + equipment: weaponType, + locations: new Set(['LA']), + }); + forceUnit.setCritSlots([ammoSlot], true); + forceUnit.setInventory([weapon], true); + const [source] = getInventoryControlAmmoSelectionOptions( + weapon, + forceUnit.getEquipmentRegistry(), + ); + forceUnit.setInventoryControlEntryAmmoSelection(weapon.id, { + selectedProfileId: source.profileId, + preferredSourceOptionId: source.id, + }); + + forceUnit.setInternalHits('LT', forceUnit.getInternalPoints('LT')); + + expect(forceUnit.getEquipmentStatus(forceUnit.getCritSlot('LT', 0)!)).toBe('destroyed'); + expect(forceUnit.getInventoryControlEntryAmmoSelection(weapon.id)).toEqual({ + selectedProfileId: source.profileId, + preferredSourceOptionId: null, + }); + }); + it('repairAll restores intrinsic ammo from its runtime mount baseline', () => { const forceUnit = createForceUnit(); const weapon = new WeaponEquipment({ @@ -3464,6 +3709,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { ); const warning = spyOn(console, 'warn'); initialize(restored, createKamisoriAInventorySvg()); + TestBed.tick(); const capacitor = restored.getInventory().find(entry => entry.id === originalCapacitor.id)!; expect(capacitor.committedDestroyed()).toBeFalse(); @@ -3536,13 +3782,18 @@ describe('CBTForceUnit direct inventory ammo bins', () => { name: unknownEquipment.name, equipment: unknownEquipment, }); - initialize(forceUnit); const warning = spyOn(console, 'warn'); + initialize(forceUnit); + forceUnit.setInventory([entry], true); + TestBed.tick(); - expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); - expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); expect(warning).toHaveBeenCalledTimes(1); expect(warning).toHaveBeenCalledWith(jasmine.stringContaining(entry.id)); + warning.calls.reset(); + + expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); + expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); + expect(warning).not.toHaveBeenCalled(); entry.setCommittedDestroyed(true); expect(forceUnit.getEquipmentStatus(entry)).toBe('destroyed'); @@ -3562,14 +3813,19 @@ describe('CBTForceUnit direct inventory ammo bins', () => { name: unknownEquipment.name, equipment: unknownEquipment, }); - initialize(forceUnit); const warning = spyOn(console, 'warn'); + initialize(forceUnit); + forceUnit.setInventory([entry], true); + TestBed.tick(); - expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); - expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); expect(warning).toHaveBeenCalledTimes(1); expect(warning).toHaveBeenCalledWith(jasmine.stringContaining(entry.id)); expect(warning.calls.mostRecent().args[0]).not.toContain('(component'); + warning.calls.reset(); + + expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); + expect(forceUnit.getEquipmentStatus(entry)).toBe('available'); + expect(warning).not.toHaveBeenCalled(); }); it('applies damage through canonical resolved state and cancels a pending repair', () => { @@ -4128,7 +4384,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { const forceUnit = createForceUnit(createMmlUnit(equipment)); initialize(forceUnit, createMmlSvg()); const weaponEntry = forceUnit.getInventory().find(entry => entry.equipment instanceof WeaponEquipment)!; - const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgService(forceUnit, unitInitializer)); + const svgService = TestBed.runInInjectionContext(() => new ExposedUnitSvgVehicleService(forceUnit, unitInitializer)); forceUnit.createInventoryControlTarget(); forceUnit.updateInventoryControlTarget('A', { distance: 7 }); diff --git a/src/app/models/cbt-force-unit.model.ts b/src/app/models/cbt-force-unit.model.ts index 8b0ae496c..8a3cb9914 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import { computed, createEnvironmentInjector, effect, type EffectRef, EnvironmentInjector, type Injector, runInInjectionContext, signal, type Signal, untracked, type WritableSignal } from '@angular/core'; +import { computed, createEnvironmentInjector, effect, type EffectRef, EnvironmentInjector, type Injector, isDevMode, runInInjectionContext, signal, type Signal, untracked, type WritableSignal } from '@angular/core'; import { DataService } from '../services/data.service'; import type { Unit } from "./units.model"; import type { UnitInitializerService } from '../services/unit-initializer.service'; @@ -292,6 +292,7 @@ export class CBTForceUnit extends ForceUnit { throw new Error(`Unit "${this.unit.name}" loaded but SVG is missing`); } this.isLoaded.set(true); + if (isDevMode()) this.reportUnknownDirectInventoryInstallationLocations(); this.reconcileRuleChecks(); } finally { // Clear the loading promise when done (success or failure) @@ -441,6 +442,7 @@ export class CBTForceUnit extends ForceUnit { } setCritSlot(slot: CriticalSlot) { + this.turnState().markEquipmentStateChanged(); const crits = [...this.state.crits()]; const existingIndex = crits.findIndex(c => c.loc === slot.loc && c.slot === slot.slot); if (existingIndex !== -1) { @@ -462,6 +464,7 @@ export class CBTForceUnit extends ForceUnit { if (consolidateImmediately) { this.dispatchBeforeEquipmentStateCommit(); this.state.consolidateCrits(); // Consolidate immediately in case we have pending hits to apply + this.inventoryControl.markAmmoSourcesChanged(); } this._rules.evaluateCritSlotHit(slot); } @@ -496,6 +499,7 @@ export class CBTForceUnit extends ForceUnit { setInventory(inventory: MountedEquipment[], initialization: boolean = false) { this.state.inventory.set(MountedEquipment.fromAll(inventory)); + if (this.isLoaded() && isDevMode()) this.reportUnknownDirectInventoryInstallationLocations(); this.turnState().reconcileHeatSources(); if (!initialization) { this.turnState().clampMoveDistanceToCurrentModeRange(); @@ -506,7 +510,8 @@ export class CBTForceUnit extends ForceUnit { } } - setInventoryEntry(inventoryEntry: MountedEquipment) { + setInventoryEntry(inventoryEntry: MountedEquipment, options: { phaseChange?: boolean } = {}) { + if (options.phaseChange !== false) this.turnState().markEquipmentStateChanged(); const inventory = [...this.state.inventory()]; const existingIndex = inventory.findIndex(item => item.id === inventoryEntry.id); if (existingIndex !== -1) { @@ -678,6 +683,7 @@ export class CBTForceUnit extends ForceUnit { setLocations(locations: Record, initialization: boolean = false) { this.state.locations.set(locations); if (!initialization) { + this.markEquipmentLocationsChanged(); this.evaluateDestroyed(); this.setModified(); } @@ -711,6 +717,7 @@ export class CBTForceUnit extends ForceUnit { locations[locKey].pendingArmor += hits; } this.state.locations.set({ ...this.state.locations(), [locKey]: locations[locKey] }); + this.markEquipmentLocationsChanged(); let hitsForPsr = hits; if (this.getUnit().armorType === 'Hardened') { hitsForPsr = Math.ceil(hitsForPsr / 2); @@ -729,6 +736,7 @@ export class CBTForceUnit extends ForceUnit { locations[locKey].armor = hits; locations[locKey].pendingArmor = undefined; this.state.locations.set({ ...this.state.locations(), [locKey]: locations[locKey] }); + this.markEquipmentLocationsChanged(); this.evaluateDestroyed(); this.setModified(); } @@ -757,6 +765,7 @@ export class CBTForceUnit extends ForceUnit { locations[loc].pendingInternal += hits; } this.state.locations.set({ ...this.state.locations(), [loc]: locations[loc] }); + this.markEquipmentLocationsChanged(); this.state.turnState().addDmgReceived(hits); this._rules.evaluateLegDestroyed(loc, hits); this.clearNarcFromCommittedPhysicallyDestroyedLocations(); @@ -772,11 +781,16 @@ export class CBTForceUnit extends ForceUnit { locations[loc].internal = hits; locations[loc].pendingInternal = undefined; this.state.locations.set({ ...this.state.locations(), [loc]: locations[loc] }); + this.markEquipmentLocationsChanged(); this.clearNarcFromCommittedPhysicallyDestroyedLocations(); this.evaluateDestroyed(); this.setModified(); } + private markEquipmentLocationsChanged(): void { + this.inventoryControl.markAmmoSourcesChanged(); + } + getLocationConditions(loc: string): ReadonlyMap { return conditionsMapFromSerialization(this.state.locations()[loc]?.conditions); } @@ -1064,7 +1078,6 @@ export class CBTForceUnit extends ForceUnit { if (parentLocations.length > 0) return parentLocations; } - this.reportUnknownDirectInventoryInstallationLocation(entry, componentRef); return locations; } @@ -1099,12 +1112,22 @@ export class CBTForceUnit extends ForceUnit { return this.battleArmorTrooperLocation(normalized) ?? normalized; } + private reportUnknownDirectInventoryInstallationLocations(): void { + if (!this.hasDirectInventory()) return; + for (const entry of this.getInventory()) { + const componentRef = parseInventoryComponentReference(entry.id); + const locations = this.getEquipmentInstallationLocations(entry, this.getCurrentCriticalSlots(entry)); + if (locations.length === 0) { + this.reportUnknownDirectInventoryInstallationLocation(entry, componentRef); + } + } + } + private reportUnknownDirectInventoryInstallationLocation( entry: MountedEquipment, componentRef: ReturnType, ): void { - if (!this.isLoaded() || !this.hasDirectInventory() - || entry.isIntrinsicPhysicalAttack() || !entry.equipment + if (entry.isIntrinsicPhysicalAttack() || !entry.equipment || this.unknownEquipmentInstallationLocationIds.has(entry.id)) return; this.unknownEquipmentInstallationLocationIds.add(entry.id); const componentLabel = componentRef === null ? '' : ` (component ${componentRef.componentIndex})`; @@ -1469,6 +1492,7 @@ export class CBTForceUnit extends ForceUnit { endPhase() { this.dispatchBeforeEquipmentStateCommit(); this.state.endPhase(); + this.inventoryControl.markAmmoSourcesChanged(); this.phaseTrigger.update(v => v + 1); // Trigger change detection } @@ -1520,6 +1544,7 @@ export class CBTForceUnit extends ForceUnit { const notifications = this.injector.get(ToastService); this.forEachCurrentInventoryEntry(entry => equipmentRegistry.onEndTurn(entry, notifications)); this.state.endTurn(); + this.inventoryControl.markAmmoSourcesChanged(); this.phaseTrigger.update(v => v + 1); // Trigger change detection this.state.resetTurnState(); } diff --git a/src/app/models/cbt-inventory-control-runtime.model.spec.ts b/src/app/models/cbt-inventory-control-runtime.model.spec.ts index 5bdd89237..82dd9ce68 100644 --- a/src/app/models/cbt-inventory-control-runtime.model.spec.ts +++ b/src/app/models/cbt-inventory-control-runtime.model.spec.ts @@ -30,6 +30,22 @@ describe('CBTInventoryControlRuntime ammo selection reconciliation', () => { }); }); + it('recovers a profile from a valid preferred source when the persisted profile is stale', () => { + const fixture = createAmmoFixture(); + const rightArmSourceId = `${fixture.standard.internalName}:RA`; + + fixture.harness.unit.setInventoryControlEntryAmmoSelection(fixture.weapon.id, { + selectedProfileId: 'removed-profile', + preferredSourceOptionId: rightArmSourceId, + }); + fixture.harness.runtime.markAmmoSourcesChanged(); + + expect(fixture.harness.unit.getInventoryControlEntryAmmoSelection(fixture.weapon.id)).toEqual({ + selectedProfileId: getInventoryControlAmmoProfileId(fixture.standard), + preferredSourceOptionId: rightArmSourceId, + }); + }); + it('uses current location-qualified group IDs and keeps equivalent sources as one profile', () => { const fixture = createAmmoFixture(); diff --git a/src/app/models/force-serialization.ts b/src/app/models/force-serialization.ts index 5c9cd4beb..344259d2d 100644 --- a/src/app/models/force-serialization.ts +++ b/src/app/models/force-serialization.ts @@ -90,6 +90,7 @@ export interface SerializedTurnState { psrChecks?: SerializedPSRChecks; applyMovePSR?: boolean; spotting?: boolean; + equipmentStateChanged?: boolean; } export interface SerializedForce { @@ -450,6 +451,7 @@ export const TURN_STATE_SCHEMA = Sanitizer.schema() .custom('psrChecks', sanitizePSRChecks) .custom('applyMovePSR', (value: unknown) => typeof value === 'boolean' ? value : undefined) .custom('spotting', (value: unknown) => typeof value === 'boolean' ? value : undefined) + .custom('equipmentStateChanged', (value: unknown) => value === true ? true : undefined) .build(); export const LOCATION_SCHEMA = Sanitizer.schema() diff --git a/src/app/models/inventory-control-runtime-state.model.ts b/src/app/models/inventory-control-runtime-state.model.ts index 520e567e2..81a2d5d36 100644 --- a/src/app/models/inventory-control-runtime-state.model.ts +++ b/src/app/models/inventory-control-runtime-state.model.ts @@ -115,6 +115,23 @@ export interface InventoryControlRuntimeAmmoProfileIdentity { readonly profileId: string; } +export function resolveInventoryControlSelectedAmmoProfileId( + profileOptions: readonly InventoryControlRuntimeAmmoProfileIdentity[], + selectedProfileId?: string | null, + preferredSourceOptionId?: string | null, + sourceOptions: readonly Pick[] = [], +): string | undefined { + if (selectedProfileId && profileOptions.some(option => option.profileId === selectedProfileId)) { + return selectedProfileId; + } + const preferredProfileId = preferredSourceOptionId + ? sourceOptions.find(option => option.id === preferredSourceOptionId)?.profileId + : undefined; + return preferredProfileId && profileOptions.some(option => option.profileId === preferredProfileId) + ? preferredProfileId + : profileOptions[0]?.profileId; +} + export function reconcileInventoryControlRuntimeAmmoSelection( selection: InventoryControlRuntimeAmmoSelection | undefined, sourceOptions: readonly InventoryControlRuntimeAmmoOptionIdentity[], @@ -125,15 +142,13 @@ export function reconcileInventoryControlRuntimeAmmoSelection( const preferredSource = selection.preferredSourceOptionId ? sourceOptions.find(option => option.id === selection.preferredSourceOptionId) : undefined; - const persistedProfileId = selection.selectedProfileId - && profileOptions.some(option => option.profileId === selection.selectedProfileId) - ? selection.selectedProfileId - : null; - const preferredProfileId = preferredSource - && profileOptions.some(option => option.profileId === preferredSource.profileId) - ? preferredSource.profileId - : null; - const selectedProfileId = persistedProfileId ?? preferredProfileId ?? profileOptions[0].profileId; + const selectedProfileId = resolveInventoryControlSelectedAmmoProfileId( + profileOptions, + selection.selectedProfileId, + selection.preferredSourceOptionId, + sourceOptions, + ); + if (!selectedProfileId) return undefined; return { selectedProfileId, diff --git a/src/app/models/turn-state.model.spec.ts b/src/app/models/turn-state.model.spec.ts index 0e6990b6a..98b4664bf 100644 --- a/src/app/models/turn-state.model.spec.ts +++ b/src/app/models/turn-state.model.spec.ts @@ -464,6 +464,27 @@ describe('TurnState', () => { expect(restored.dirtyPhase()).toBeFalse(); }); + it('persists equipment phase changes until they are committed', () => { + const { turnState } = createTurnStateHarness(); + + turnState.markEquipmentStateChanged(); + + expect(turnState.dirty()).toBeTrue(); + expect(turnState.dirtyPhase()).toBeTrue(); + expect(turnState.serialize()).toEqual({ equipmentStateChanged: true }); + + const { turnState: restored } = createTurnStateHarness(); + restored.update(turnState.serialize()); + + expect(restored.dirtyPhase()).toBeTrue(); + + restored.commitEquipmentStateChanges(); + + expect(restored.dirty()).toBeFalse(); + expect(restored.dirtyPhase()).toBeFalse(); + expect(restored.serialize()).toBeUndefined(); + }); + it('preserves applied heat sources without serializing derived source values', () => { const { turnState } = createTurnStateHarnessWithDissipation(5); turnState.moveMode.set('run'); diff --git a/src/app/models/turn-state.model.ts b/src/app/models/turn-state.model.ts index 679fd6d8f..f693ded92 100644 --- a/src/app/models/turn-state.model.ts +++ b/src/app/models/turn-state.model.ts @@ -56,6 +56,7 @@ export class TurnState { private readonly acknowledgedHeatSources = this.modifiedSignal>({}); private readonly heatDissipationConsumed = this.modifiedSignal(0); private readonly psrOutcomes = this.modifiedSignal>({}); + private readonly equipmentStateChanged = this.modifiedSignal(false); airborne = this.modifiedSignal(null, 'movement'); moveMode = this.modifiedSignal(null, 'movement'); moveDistance = this.modifiedSignal(null, 'movement'); @@ -85,6 +86,7 @@ export class TurnState { || unconsolidatedCrits || unconsolidatedLocations || unconsolidatedInventory + || this.equipmentStateChanged() || this.passiveHeatSourceSignature() !== this.passiveHeatSourceBaseline() || Object.keys(this.acknowledgedHeatSources()).length > 0 || this.heatDissipationConsumed() > 0 @@ -100,7 +102,8 @@ export class TurnState { || this.hasPendingPSRChecks() || unconsolidatedCrits || unconsolidatedLocations - || unconsolidatedInventory; + || unconsolidatedInventory + || this.equipmentStateChanged(); }); autoFall = computed(() => { @@ -373,6 +376,7 @@ export class TurnState { if (psrChecks) turnState.psrChecks = psrChecks; if (!this.applyMovePSR()) turnState.applyMovePSR = false; if (this.spotting()) turnState.spotting = true; + if (this.equipmentStateChanged()) turnState.equipmentStateChanged = true; return Object.keys(turnState).length > 0 ? turnState : undefined; } @@ -390,9 +394,18 @@ export class TurnState { this.psrChecks.set(this.deserializePSRChecks(data?.psrChecks)); this.applyMovePSR.set(data?.applyMovePSR ?? true); this.spotting.set(data?.spotting ?? false); + this.equipmentStateChanged.set(data?.equipmentStateChanged ?? false); }); } + markEquipmentStateChanged(): void { + this.equipmentStateChanged.set(true); + } + + commitEquipmentStateChanges(): void { + this.equipmentStateChanged.set(false); + } + private serializePSRChecks(): SerializedPSRChecks | undefined { const psrChecks = this.getPSRCheckState(); const serialized: SerializedPSRChecks = {}; @@ -525,4 +538,4 @@ export class TurnState { return Math.max(0, rulesMinDistance ?? 0); }); -} \ No newline at end of file +} diff --git a/src/app/services/equipment-interaction-registry.service.spec.ts b/src/app/services/equipment-interaction-registry.service.spec.ts index 264c09eda..26c4546c4 100644 --- a/src/app/services/equipment-interaction-registry.service.spec.ts +++ b/src/app/services/equipment-interaction-registry.service.spec.ts @@ -16,7 +16,7 @@ import { EquipmentRegistry } from '../models/equipment-lookup'; import { MountedEquipment } from '../models/mounted-equipment.model'; import { CORE_2026_GAME_RULES, TW_GAME_RULES, type CBTGameRules } from '../models/rules/game-rules'; import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../models/rules/unit-type-rules'; -import { createEmptyUnit } from '../testing/unit-test-helpers'; +import { createEmptyUnit, createTestEquipmentOwner } from '../testing/unit-test-helpers'; import { createHandlerCommandContext, createHandlerQueryContext, @@ -349,14 +349,8 @@ describe('EquipmentInteractionRegistryService', () => { type: 'ammo', ammo: { type: 'LRM', rackSize: 10, shots: 12 }, }); - let inventory: MountedEquipment[] = []; - const ammoOwner = { - readOnly: () => true, - getCritSlots: () => [], - getInventory: () => inventory, - isEquipmentOperational: () => true, - canPerformEquipmentAction: () => true, - } as never; + const ownerFixture = createTestEquipmentOwner({ readOnly: true }); + const ammoOwner = ownerFixture.owner; const weapon = new MountedEquipment({ owner: ammoOwner, id: 'lrm-10', @@ -373,7 +367,7 @@ describe('EquipmentInteractionRegistryService', () => { equipment: ammo, totalAmmo: 12, }); - inventory = [weapon, ammoMount]; + ownerFixture.inventory.push(weapon, ammoMount); const equipmentCatalog = new EquipmentRegistry({ [ammo.internalName]: ammo }); const dialogsService = jasmine.createSpyObj( 'HandlerDialogsService', diff --git a/src/app/services/unit-svg-mek.service.ts b/src/app/services/unit-svg-mek.service.ts index 29e0ef051..0f7437bee 100644 --- a/src/app/services/unit-svg-mek.service.ts +++ b/src/app/services/unit-svg-mek.service.ts @@ -8,7 +8,6 @@ import type { CriticalSlot } from "../models/force-serialization"; import { UnitSvgService } from "./unit-svg.service"; import { AmmoEquipment } from "../models/equipment.model"; import { MekRules } from "../models/rules/mek-rules"; -import type { InventoryControlRuntimeRangeKey } from "../models/inventory-control-runtime-state.model"; import { getCriticalSlotAmmoProfileKey } from "../utils/ammo-interaction.util"; import { INVENTORY_CONTROL_PHYSICAL_BASE_DAMAGE_TEXT_ATTRIBUTE, readInventoryControlDisplayData } from "../utils/inventory-control.util"; @@ -201,29 +200,11 @@ export class UnitSvgMekService extends UnitSvgService { this.renderMeleeDamage(entry, 'physWeapon', undefined, !!entry.equipment?.flags.has('S_FLAIL')); } - const actionUnavailable = !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire'); - entry.el.classList.toggle('disabledInventory', actionUnavailable); - const destroyed = this.unit.getEquipmentStatus(entry) === 'destroyed'; - entry.el.classList.toggle('damagedInventory', destroyed); - if (destroyed || actionUnavailable) entry.el.classList.remove('selected'); - - // Hit modifier badge - this.renderHitModEntry(entry, this.resolveInventoryControlToHit(entry)); + this.renderInventoryEntryState(entry); }); this.renderInventoryControlSelection(); } - protected override resolveInventoryControlToHit(entry: MountedEquipment, range?: InventoryControlRuntimeRangeKey | null) { - const stateModifiers = this.mekRules.getEquipmentToHitModifiers(entry); - const selectedAmmo = this.inventoryTargetSelectedAmmo(entry); - return this.unit.gameRules.resolveToHit({ - subject: entry, - stateModifiers, - range, - adjustments: this.unit.getInventoryControlRules().resolveToHitAdjustments?.(entry, selectedAmmo) - }); - } - protected override updateTurnState() { super.updateTurnState(); diff --git a/src/app/services/unit-svg-vehicle.service.ts b/src/app/services/unit-svg-vehicle.service.ts index 8445aedc0..18f532f22 100644 --- a/src/app/services/unit-svg-vehicle.service.ts +++ b/src/app/services/unit-svg-vehicle.service.ts @@ -2,10 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Author: Drake -import type { MountedEquipment } from "../models/mounted-equipment.model"; import type { CriticalSlot } from "../models/force-serialization"; import { VehicleRules } from "../models/rules/vehicle-rules"; -import type { InventoryControlRuntimeRangeKey } from "../models/inventory-control-runtime-state.model"; import { committedCriticalHitCount, isRepeatableMotiveHitId, MOTIVE_HIT_PIP_COUNT } from "../models/rules/vehicle-motive-hit.util"; import { UnitSvgService } from "./unit-svg.service"; @@ -132,26 +130,9 @@ export class UnitSvgVehicleService extends UnitSvgService { this.renderChargeDamage(entry, this.vehicleRules.chargeDamage()); } } - const actionUnavailable = !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire'); - entry.el.classList.toggle('disabledInventory', actionUnavailable); - const destroyed = this.unit.getEquipmentStatus(entry) === 'destroyed'; - entry.el.classList.toggle('damagedInventory', destroyed); - if (destroyed || actionUnavailable) entry.el.classList.remove('selected'); - - this.renderHitModEntry(entry, this.resolveInventoryControlToHit(entry)); + this.renderInventoryEntryState(entry); }); this.renderInventoryControlSelection(); } - protected override resolveInventoryControlToHit(entry: MountedEquipment, range?: InventoryControlRuntimeRangeKey | null) { - const stateModifiers = this.vehicleRules.getEquipmentToHitModifiers(entry); - const selectedAmmo = this.inventoryTargetSelectedAmmo(entry); - return this.unit.gameRules.resolveToHit({ - subject: entry, - stateModifiers, - range, - adjustments: this.unit.getInventoryControlRules().resolveToHitAdjustments?.(entry, selectedAmmo) - }); - } - } diff --git a/src/app/services/unit-svg.service.ts b/src/app/services/unit-svg.service.ts index 237d9ec42..a48012b03 100644 --- a/src/app/services/unit-svg.service.ts +++ b/src/app/services/unit-svg.service.ts @@ -17,7 +17,7 @@ import { formatGunneryDisplay, formatPilotingDisplay, UNIT_CONDITION_DEFINITIONS import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model'; import { formatAmmoName } from '../utils/ammo-interaction.util'; import { inventoryTargetCategory, inventoryTargetNumberText, inventoryTargetRangeSelection } from '../utils/inventory-target-number.util'; -import { getInventoryControlGroups, getInventoryControlModes, getSelectedInventoryControlMode, INVENTORY_CONTROL_ORIGINAL_DAMAGE_TEXT_ATTRIBUTE, INVENTORY_CONTROL_PHYSICAL_BASE_DAMAGE_TEXT_ATTRIBUTE, readInventoryControlDisplayData, syncSvgMode, type InventoryControlAmmoOption, type InventoryControlRow } from '../utils/inventory-control.util'; +import { getInventoryControlGroups, getInventoryControlModes, getSelectedInventoryControlMode, inventoryControlEntryAction, INVENTORY_CONTROL_ORIGINAL_DAMAGE_TEXT_ATTRIBUTE, INVENTORY_CONTROL_PHYSICAL_BASE_DAMAGE_TEXT_ATTRIBUTE, readInventoryControlDisplayData, syncSvgMode, type InventoryControlAmmoOption, type InventoryControlRow } from '../utils/inventory-control.util'; import { inventoryControlDamageRange, resolveInventoryControlDamageText } from '../utils/inventory-control-damage.util'; import { formatInventoryControlHeat, resolveHeatSummarySources, resolveInventoryControlHeatEffect, resolveSelectedWeaponPreviewHeatSources } from '../utils/inventory-control-heat.util'; import { calculateHeatProjection, type HeatProjection } from '../models/turn-state.model'; @@ -1611,37 +1611,36 @@ export class UnitSvgService { if (!svg) return; this.unit.getInventory().forEach(entry => { if (!entry.el) return; - const status = this.unit.getEquipmentStatus(entry); - const actionUnavailable = !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire'); - syncSvgMode( - entry, - getSelectedInventoryControlMode(entry, this.unit.getEquipmentRegistry(), this.unit.getInventoryControlRules().matchesAmmo), - actionUnavailable, - ); if (entry.isIntrinsicPhysicalAttack()) { if (entry.name === 'charge') { this.renderChargeDamage(entry, this.unit.rules.chargeDamage()); } } - // Inventory state - entry.el.classList.toggle('disabledInventory', actionUnavailable); - const destroyed = status === 'destroyed'; - entry.el.classList.toggle('damagedInventory', destroyed); - if (destroyed || actionUnavailable) entry.el.classList.remove('selected'); - // Hit modifier badge - if (destroyed) { - this.renderHitModEntry(entry, { profile: [], value: null, changed: false, weakened: false, modifierBreakdown: [] }); - } else { - this.renderHitModEntry( - entry, - this.resolveInventoryControlToHit(entry) - ); - } - this.renderInventoryControlHeatEntry(entry, null); + this.renderInventoryEntryState(entry); }); this.renderInventoryControlSelection(); } + /** Render canonical state shared by every unit-type inventory implementation. */ + protected renderInventoryEntryState(entry: MountedEquipment): void { + if (!entry.el) return; + const status = this.unit.getEquipmentStatus(entry); + const actionUnavailable = !entry.owner.canPerformEquipmentAction(entry, inventoryControlEntryAction(entry)); + syncSvgMode( + entry, + getSelectedInventoryControlMode(entry, this.unit.getEquipmentRegistry(), this.unit.getInventoryControlRules().matchesAmmo), + actionUnavailable, + ); + entry.el.classList.toggle('disabledInventory', actionUnavailable); + const destroyed = status === 'destroyed'; + entry.el.classList.toggle('damagedInventory', destroyed); + if (destroyed || actionUnavailable) entry.el.classList.remove('selected'); + this.renderHitModEntry(entry, destroyed + ? { profile: [], value: null, changed: false, weakened: false, modifierBreakdown: [] } + : this.resolveInventoryControlToHit(entry)); + this.renderInventoryControlHeatEntry(entry, null); + } + protected renderChargeDamage(entry: MountedEquipment, chargeDamage: ChargeDamage): void { const damageEl = entry.el?.querySelector(':scope > .damage > text'); if (!damageEl) return; diff --git a/src/app/testing/unit-test-helpers.spec.ts b/src/app/testing/unit-test-helpers.spec.ts index cd48d71f1..62de66114 100644 --- a/src/app/testing/unit-test-helpers.spec.ts +++ b/src/app/testing/unit-test-helpers.spec.ts @@ -8,7 +8,43 @@ import { MountedEquipment, MountedWeapon } from '../models/mounted-equipment.mod import { type CriticalSlot } from '../models/force-serialization'; import { CORE_2026_GAME_RULES } from '../models/rules/game-rules'; import { ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE } from '../models/rules/unit-type-rules'; -import { createCBTForceUnitTestHarness } from './unit-test-helpers'; +import { createCBTForceUnitTestHarness, createTestEquipmentOwner } from './unit-test-helpers'; + +describe('createTestEquipmentOwner', () => { + it('derives operational state and action permission from one canonical status', () => { + const fixture = createTestEquipmentOwner(); + const mounted = new MountedEquipment({ + owner: fixture.owner, + id: 'disabled-laser', + name: 'Disabled Laser', + states: new Map([[ENTRY_DISABLED_STATE_KEY, ENTRY_DISABLED_STATE_VALUE]]), + }); + + expect(fixture.owner.getEquipmentStatus(mounted)).toBe('disabled'); + expect(fixture.owner.isEquipmentOperational(mounted)).toBeFalse(); + expect(fixture.owner.canPerformEquipmentAction(mounted, 'fire')).toBeFalse(); + expect(fixture.owner.canEditEquipmentState(mounted, 'enable')).toBeTrue(); + expect(fixture.owner.canEditEquipmentState(mounted, 'disable')).toBeFalse(); + }); + + it('persists inventory and critical-slot writes through production-shaped methods', () => { + const fixture = createTestEquipmentOwner(); + const mounted = new MountedEquipment({ + owner: fixture.owner, + id: 'laser', + name: 'Laser', + }); + const slot: CriticalSlot = { id: 'Laser@RA#1', loc: 'RA', slot: 1 }; + + fixture.owner.setInventoryEntry(mounted); + fixture.owner.setCritSlots([slot]); + + expect(fixture.owner.getInventory()).toEqual([mounted]); + expect(fixture.inventoryWrites).toEqual([mounted]); + expect(fixture.owner.getCritSlots()).toEqual([slot]); + expect(fixture.criticalSlotWrites).toEqual([[slot]]); + }); +}); describe('CBTForceUnitTestHarness', () => { it('adds mounted components and registers their equipment', () => { @@ -55,7 +91,7 @@ describe('CBTForceUnitTestHarness', () => { expect(harness.unit.isInventoryControlEntrySelected(mounted.id)).toBeTrue(); }); - it('resolves mounted and direct critical status from the current slot identity', () => { + it('resolves direct critical status from the current slot identity without aggregating the mount', () => { const harness = createCBTForceUnitTestHarness(); const equipment = new WeaponEquipment({ id: 'TestLaser', name: 'Test Laser', type: 'weapon' }); const snapshot: CriticalSlot = { id: 'TestLaser@RA#0', loc: 'RA', slot: 0, eq: equipment }; @@ -69,7 +105,7 @@ describe('CBTForceUnitTestHarness', () => { harness.addCriticalSlot({ ...snapshot, destroyed: 1 }); expect(harness.unit.getEquipmentStatus(snapshot)).toBe('destroyed'); - expect(harness.unit.getEquipmentStatus(mounted)).toBe('destroyed'); + expect(harness.unit.getEquipmentStatus(mounted)).toBe('available'); harness.addCriticalSlot({ ...snapshot, destroyed: undefined }); @@ -77,7 +113,7 @@ describe('CBTForceUnitTestHarness', () => { expect(harness.unit.getEquipmentStatus(mounted)).toBe('available'); }); - it('uses the Mek two-critical destruction threshold for autocannons', () => { + it('does not invent subtype-specific mounted critical aggregation', () => { const harness = createCBTForceUnitTestHarness(); const autocannon = new WeaponEquipment({ id: 'ISAC5', @@ -94,16 +130,12 @@ describe('CBTForceUnitTestHarness', () => { critSlots: [first, second], }); - harness.addCriticalSlot({ ...first, destroyed: 1 }); + const firstCritical = harness.addCriticalSlot({ ...first, destroyed: 1 }); harness.addCriticalSlot(second); + expect(harness.unit.getEquipmentStatus(firstCritical)).toBe('destroyed'); expect(harness.unit.getEquipmentStatus(mounted)).toBe('available'); expect(harness.unit.getEquipmentStatusAtLocation(mounted, 'RA')).toBe('available'); - - harness.addCriticalSlot({ ...second, destroyed: 2 }); - - expect(harness.unit.getEquipmentStatus(mounted)).toBe('destroyed'); - expect(harness.unit.getEquipmentStatusAtLocation(mounted, 'RA')).toBe('destroyed'); }); it('provides production-default game rules and equipment disabled state', () => { @@ -169,6 +201,34 @@ describe('CBTForceUnitTestHarness', () => { expect(defaultHarness.unit.canPerformEquipmentAction(defaultMounted, 'configure-network')).toBeFalse(); }); + it('applies unit availability gates before subtype action permission', () => { + const resolveEquipmentActionPermission = jasmine.createSpy('resolveEquipmentActionPermission') + .and.returnValue(true); + const destroyedHarness = createCBTForceUnitTestHarness({ + destroyed: true, + resolveEquipmentActionPermission, + }); + const mounted = destroyedHarness.addComponent({ id: 'laser', name: 'Laser' }); + + expect(destroyedHarness.unit.canPerformEquipmentAction(mounted, 'fire')).toBeFalse(); + expect(resolveEquipmentActionPermission).not.toHaveBeenCalled(); + }); + + it('requires an explicitly operational C3 component before configuring its network', () => { + const resolveConfigureNetworkPermission = jasmine.createSpy('resolveConfigureNetworkPermission') + .and.returnValue(true); + const harness = createCBTForceUnitTestHarness({ resolveConfigureNetworkPermission }); + const mounted = harness.addComponent({ id: 'c3-master', name: 'C3 Master' }); + + expect(harness.unit.canPerformEquipmentAction(mounted, 'configure-network')).toBeTrue(); + expect(resolveConfigureNetworkPermission).toHaveBeenCalledOnceWith(mounted); + + harness.setEquipmentStatus(mounted, 'destroyed'); + + expect(harness.unit.canPerformEquipmentAction(mounted, 'configure-network')).toBeFalse(); + expect(resolveConfigureNetworkPermission).toHaveBeenCalledTimes(1); + }); + it('resolves lifecycle state through canonical unit helpers', () => { const harness = createCBTForceUnitTestHarness({ resolveEquipmentStatus: () => 'destroyed', @@ -185,7 +245,7 @@ describe('CBTForceUnitTestHarness', () => { expect(harness.unit.isEquipmentResolvedCommittedDestroyed(mounted)).toBeFalse(); }); - it('keeps installation-location loss separate from a repairing mount', () => { + it('blocks repair when the equipment installation location is destroyed', () => { const harness = createCBTForceUnitTestHarness(); const mounted = harness.addComponent({ id: 'laser', @@ -203,10 +263,17 @@ describe('CBTForceUnitTestHarness', () => { expect(harness.unit.canEditEquipmentState(mounted, 'repair')).toBeFalse(); }); - it('defaults installation-location status to available', () => { + it('keeps a destroyed mount in a healthy installation location repairable', () => { const harness = createCBTForceUnitTestHarness(); - const mounted = harness.addComponent({ id: 'laser', name: 'Laser', destroyed: true }); + const mounted = harness.addComponent({ + id: 'laser', + name: 'Laser', + destroyed: true, + locations: new Set(['RA']), + }); + expect(harness.unit.getEquipmentStatus(mounted)).toBe('destroyed'); + expect(harness.unit.getEquipmentStatusAtLocation(mounted, 'RA')).toBe('destroyed'); expect(harness.unit.getEquipmentInstallationLocationStatus(mounted)).toBe('available'); expect(harness.unit.canEditEquipmentState(mounted, 'repair')).toBeTrue(); }); diff --git a/src/app/testing/unit-test-helpers.ts b/src/app/testing/unit-test-helpers.ts index 5478131e3..9af2edf1e 100644 --- a/src/app/testing/unit-test-helpers.ts +++ b/src/app/testing/unit-test-helpers.ts @@ -184,9 +184,11 @@ export interface CBTForceUnitTestHarnessOptions { c3DegradationSource?: C3DegradationSource; allowExtremeRange?: boolean; readOnly?: boolean; + destroyed?: boolean; hasDirectInventory?: boolean; resolveEquipmentStatus?: (source: EquipmentStatusSource) => MountedEquipmentStatus; resolveEquipmentStatusAtLocation?: (entry: MountedEquipment, location: string) => MountedEquipmentStatus; + resolveConfigureNetworkPermission?: (entry: MountedEquipment) => boolean; resolveEquipmentActionPermission?: (entry: MountedEquipment, action: EquipmentAction) => boolean; getEquipmentToHitModifiers?: (entry: MountedEquipment) => readonly ToHitModifierBreakdownEntry[]; applyInventoryControlDisplayEffects?: (entry: MountedEquipment, display: InventoryControlDisplayData) => InventoryControlDisplayData; @@ -297,30 +299,14 @@ export class CBTForceUnitTestHarness { const currentCriticalSlots = (entry: MountedEquipment): CriticalSlot[] => ( entry.critSlots?.flatMap(snapshot => findCurrentCriticalSlot(snapshot) ?? []) ?? [] ); - const mountedCriticalStatusContribution = ( - destroyedCriticalCount: number, - equipmentFlags: EquipmentStatusFacts['equipmentFlags'], - ): MountedEquipmentStatus => { - const threshold = baseUnit.type === 'Mek' && equipmentFlags.has('F_AC') ? 2 : 1; - return destroyedCriticalCount >= threshold ? 'destroyed' : 'available'; - }; - const mountedCriticalStatus = ( - entry: MountedEquipment, - criticalSlots: readonly CriticalSlot[], - ): MountedEquipmentStatus => mountedCriticalStatusContribution( - criticalSlots.filter(slot => !!slot.destroyed).length, - entry.equipment?.flags ?? new Set(), - ); const resolveEquipmentStatus = (source: EquipmentStatusSource): MountedEquipmentStatus => { if (options.resolveEquipmentStatus) return options.resolveEquipmentStatus(source); if (!(source instanceof MountedEquipment)) { return findCurrentCriticalSlot(source)?.destroyed ? 'destroyed' : 'available'; } const entryStatus = this.equipmentStatuses.get(source) ?? defaultEquipmentStatus(source); - const criticalStatus = mountedCriticalStatus(source, currentCriticalSlots(source)); return combineEquipmentStatuses([ entryStatus, - criticalStatus, ...(this.equipmentStatusesAtLocation.get(source)?.values() ?? []), ]); }; @@ -333,11 +319,7 @@ export class CBTForceUnitTestHarness { } const entryStatus = this.equipmentStatuses.get(entry) ?? defaultEquipmentStatus(entry); const locationStatus = this.equipmentStatusesAtLocation.get(entry)?.get(location) ?? 'available'; - const criticalStatus = mountedCriticalStatus( - entry, - currentCriticalSlots(entry).filter(slot => slot.loc === location), - ); - return combineEquipmentStatuses([entryStatus, locationStatus, criticalStatus]); + return combineEquipmentStatuses([entryStatus, locationStatus]); }; const resolveEquipmentInstallationLocationStatus = (entry: MountedEquipment): MountedEquipmentStatus => { const locations = new Set([ @@ -346,7 +328,7 @@ export class CBTForceUnitTestHarness { ]); return combineEquipmentStatuses(Array.from( locations, - location => resolveEquipmentStatusAtLocation(entry, location), + location => this.equipmentStatusesAtLocation.get(entry)?.get(location) ?? 'available', )); }; const getEquipmentToHitModifiers = (entry: MountedEquipment) => options.getEquipmentToHitModifiers?.(entry) @@ -354,10 +336,7 @@ export class CBTForceUnitTestHarness { ?? []; const rules = { getEquipmentStatusContribution: () => 'available' as const, - getMountedCriticalStatusContribution: (facts: EquipmentStatusFacts) => mountedCriticalStatusContribution( - facts.criticals.filter(critical => critical.status === 'destroyed').length, - facts.equipmentFlags, - ), + getMountedCriticalStatusContribution: (_facts: EquipmentStatusFacts) => 'available' as const, getEquipmentStatusContributionAtLocation: () => 'available' as const, getCriticalSlotStatusContribution: () => 'available' as const, getUnitSystemStatusFacts: () => ({ engineHit: false }), @@ -380,7 +359,7 @@ export class CBTForceUnitTestHarness { getBaseGunnerySkill: () => options.gunnerySkill ?? 4, getBasePilotingSkill: () => options.pilotingSkill ?? 5, canPerformEquipmentAction: (entry: MountedEquipment, action: EquipmentAction) => - options.resolveEquipmentActionPermission?.(entry, action) ?? action !== 'configure-network', + options.resolveEquipmentActionPermission?.(entry, action) ?? true, applyInventoryControlDisplayEffects: (entry: MountedEquipment, display: InventoryControlDisplayData) => options.applyInventoryControlDisplayEffects?.(entry, display) ?? display }; @@ -417,6 +396,7 @@ export class CBTForceUnitTestHarness { ); }, readOnly: () => options.readOnly ?? false, + destroyed: options.destroyed ?? false, hasDirectInventory: () => options.hasDirectInventory ?? true, setInventory: (inventory: MountedEquipment[]) => { const nextInventory = [...inventory]; @@ -447,8 +427,11 @@ export class CBTForceUnitTestHarness { this.unit.getEquipmentInstallationLocationStatus(entry) === 'destroyed' || (!entry.isRepairing() && resolveEquipmentStatus(entry) === 'destroyed'), canPerformEquipmentAction: (entry: MountedEquipment, action: EquipmentAction) => { - if (action !== 'configure-network' - && (resolveEquipmentStatus(entry) !== 'available' || conditions.has('shutdown'))) return false; + if (resolveEquipmentStatus(entry) !== 'available' + || (options.destroyed ?? false) + || conditions.has('shutdown')) return false; + if (action === 'configure-network' + && !(options.resolveConfigureNetworkPermission?.(entry) ?? false)) return false; return rules.canPerformEquipmentAction(entry, action); }, canEditEquipmentState: (entry: MountedEquipment, edit: EquipmentStateEdit) => { @@ -583,6 +566,101 @@ export function createCBTForceUnitTestHarness(options: CBTForceUnitTestHarnessOp return new CBTForceUnitTestHarness(options); } +export interface TestEquipmentOwnerOptions { + id?: string; + unit?: TestUnitOverrides; + gameRules?: CBTGameRules; + readOnly?: boolean; + destroyed?: boolean; + conditions?: readonly string[]; + inventory?: readonly MountedEquipment[]; + criticalSlots?: readonly CriticalSlot[]; + equipmentStatuses?: ReadonlyMap; + resolveEquipmentStatus?: (source: EquipmentStatusSource) => MountedEquipmentStatus; + resolveEquipmentActionPermission?: (entry: MountedEquipment, action: EquipmentAction) => boolean; +} + +export interface TestEquipmentOwnerFixture { + readonly owner: CBTForceUnit; + readonly inventory: MountedEquipment[]; + readonly inventoryWrites: MountedEquipment[]; + readonly criticalSlots: CriticalSlot[]; + readonly criticalSlotWrites: CriticalSlot[][]; +} + +/** + * Focused owner for equipment-handler unit tests. Status, operational state, and + * action permission deliberately share one production-shaped resolution path. + */ +export function createTestEquipmentOwner(options: TestEquipmentOwnerOptions = {}): TestEquipmentOwnerFixture { + const inventory = [...(options.inventory ?? [])]; + const inventoryWrites: MountedEquipment[] = []; + const criticalSlots = [...(options.criticalSlots ?? [])]; + const criticalSlotWrites: CriticalSlot[][] = []; + const conditions = new Set(options.conditions ?? []); + const unit = createEmptyUnit(options.unit); + let owner!: CBTForceUnit; + + const resolveEquipmentStatus = (source: EquipmentStatusSource): MountedEquipmentStatus => { + const configured = options.resolveEquipmentStatus?.(source) + ?? options.equipmentStatuses?.get(source); + if (configured) return configured; + if (source instanceof MountedEquipment) return defaultEquipmentStatus(source); + return source.destroyed ? 'destroyed' : 'available'; + }; + + owner = { + id: options.id ?? 'test-equipment-owner', + gameRules: options.gameRules ?? CORE_2026_GAME_RULES, + destroyed: options.destroyed ?? false, + readOnly: () => options.readOnly ?? false, + getUnit: () => unit, + getCondition: (condition: string) => conditions.has(condition), + getInventory: () => inventory, + setInventoryEntry: (entry: MountedEquipment, _options: { phaseChange?: boolean } = {}) => { + const existingIndex = inventory.findIndex(candidate => candidate.id === entry.id); + if (existingIndex === -1) inventory.push(entry); + else inventory[existingIndex] = entry; + inventoryWrites.push(entry); + }, + getCritSlots: () => criticalSlots, + findCurrentCriticalSlot: (snapshot: CriticalSlot) => { + const matches = criticalSlots.filter(candidate => ( + snapshot.loc && snapshot.slot !== undefined + ? candidate.loc === snapshot.loc && candidate.slot === snapshot.slot + : !!snapshot.id && candidate.id === snapshot.id + )); + if (matches.length > 1) { + throw new Error(`Duplicate critical-slot identity: ${snapshot.loc ?? snapshot.id}:${snapshot.slot ?? ''}`); + } + return matches[0] ?? null; + }, + setCritSlots: (slots: CriticalSlot[], _initialization = false) => { + criticalSlots.splice(0, criticalSlots.length, ...slots); + criticalSlotWrites.push([...slots]); + }, + getEquipmentStatus: (source: EquipmentStatusSource) => resolveEquipmentStatus(source), + isEquipmentOperational: (source: EquipmentStatusSource) => owner.getEquipmentStatus(source) === 'available', + canPerformEquipmentAction: (entry: MountedEquipment, action: EquipmentAction) => { + if (!owner.isEquipmentOperational(entry) + || owner.destroyed + || owner.getCondition('shutdown')) return false; + if (action === 'configure-network' && !options.resolveEquipmentActionPermission) return false; + return options.resolveEquipmentActionPermission?.(entry, action) ?? true; + }, + canEditEquipmentState: (entry: MountedEquipment, edit: EquipmentStateEdit) => { + if (owner.readOnly()) return false; + const status = owner.getEquipmentStatus(entry); + if (edit === 'enable') return status === 'disabled'; + if (edit === 'disable') return status === 'available'; + return false; + }, + matchesInventoryControlAmmo: () => null, + } as unknown as CBTForceUnit; + + return { owner, inventory, inventoryWrites, criticalSlots, criticalSlotWrites }; +} + function defaultEquipmentStatus(entry: MountedEquipment): MountedEquipmentStatus { return entry.committedDestroyed() ? 'destroyed' diff --git a/src/app/utils/ammo-interaction.util.spec.ts b/src/app/utils/ammo-interaction.util.spec.ts index 23d323708..fd7d0fabb 100644 --- a/src/app/utils/ammo-interaction.util.spec.ts +++ b/src/app/utils/ammo-interaction.util.spec.ts @@ -85,7 +85,7 @@ function createEntry(params: { originalTotalAmmo: params.totalAmmo ?? 5, totalAmmo: params.totalAmmo ?? 5, consumed: params.consumed ?? 0, - destroyed: false, + status: 'available', }; } @@ -121,7 +121,7 @@ function createCritEntry(params: { originalTotalAmmo: 5, totalAmmo: 5, consumed: 0, - destroyed: !!params.destroyed, + status: params.destroyed ? 'destroyed' : 'available', }; } @@ -307,10 +307,46 @@ describe('ammo interaction direct inventory groups', () => { })); expect(entries.length).toBe(2); - expect(entries.every(entry => entry.destroyed)).toBeTrue(); + expect(entries.every(entry => entry.status === 'destroyed')).toBeTrue(); expect(entries.every(entry => getAmmoEntryRemaining(entry) === 0)).toBeTrue(); }); + it('preserves disabled ammo status without presenting it as destroyed', () => { + const weapon = new WeaponEquipment({ + id: 'CLUltraAC20', + name: 'Ultra AC/20', + type: 'weapon', + weapon: { ammoType: 'AC_ULTRA', rackSize: 20 }, + }); + const ammoSlot = { + id: `${standardAmmo.internalName}@LT#1`, + name: standardAmmo.internalName, + loc: 'LT', + slot: 1, + eq: standardAmmo, + totalAmmo: 5, + consumed: 0, + } as CriticalSlot; + const owner = { + getInventory: () => ([ + { id: `${weapon.internalName}@RA#0`, name: weapon.internalName, equipment: weapon, states: new Map() }, + ]), + getCritSlots: () => ([ammoSlot]), + svg: () => null, + getEquipmentStatus: (source: MountedEquipment | CriticalSlot) => source === ammoSlot ? 'disabled' : 'available', + isEquipmentOperational: (source: MountedEquipment | CriticalSlot) => source !== ammoSlot, + } as unknown as CBTForceUnit; + + const [entry] = getAmmoControlEntriesForUnitWeapons(owner, createEquipmentCatalog({ + [weapon.internalName]: weapon, + [standardAmmo.internalName]: standardAmmo, + })); + + expect(entry.status).toBe('disabled'); + expect(getAmmoEntryRemaining(entry)).toBe(0); + expect(getAmmoControlGroups([entry])[0].status).toBe('disabled'); + }); + it('drains grouped bins from the last bin and refills the most recently drained bin', () => { const owner = createOwner(); const context = createContext({ [standardAmmo.internalName]: standardAmmo }); @@ -373,7 +409,7 @@ describe('ammo interaction direct inventory groups', () => { ]; const group = getAmmoControlGroups(entries)[0]; - expect(group.destroyed).toBeFalse(); + expect(group.status).toBe('available'); expect(getAmmoEntryRemaining(entries[0])).toBe(0); expect(getAmmoGroupRemaining(group)).toBe(5); expect(changeAmmoGroupRemaining(group, -1, context)).toBeTrue(); @@ -400,7 +436,7 @@ describe('ammo interaction direct inventory groups', () => { const group = getAmmoControlGroups(entries)[0]; - expect(group.destroyed).toBeTrue(); + expect(group.status).toBe('destroyed'); expect(group.expandable).toBeTrue(); expect(getAmmoGroupRemaining(group)).toBe(0); }); @@ -486,7 +522,7 @@ describe('intrinsic one-shot ammo mounts', () => { originalTotalAmmo: 1, totalAmmo: 1, consumed: 0, - destroyed: false, + status: 'available', }; setAmmoEntryValue(entry, incendiary, 99, 0); @@ -518,7 +554,7 @@ describe('intrinsic one-shot ammo mounts', () => { originalTotalAmmo: 1, totalAmmo: 1, consumed: 0, - destroyed: false, + status: 'available', }; const physicalEntry: AmmoControlEntry = { ...intrinsicEntry, diff --git a/src/app/utils/ammo-interaction.util.ts b/src/app/utils/ammo-interaction.util.ts index f0994b9f4..70d9a95c4 100644 --- a/src/app/utils/ammo-interaction.util.ts +++ b/src/app/utils/ammo-interaction.util.ts @@ -13,6 +13,7 @@ import type { HandlerCommandContext } from '../services/equipment-interaction-re import type { CBTGameRules } from '../models/rules/game-rules'; import type { Unit } from '../models/units.model'; import { normalizeBattleArmorTrooperLocation } from '../models/battle-armor-location.model'; +import { combineEquipmentStatuses, type EquipmentStatus } from '../models/equipment-status.model'; export const INTRINSIC_ONE_SHOT_AMMO_STATE = 'intrinsic_one_shot_ammo'; @@ -29,13 +30,13 @@ export interface AmmoControlEntry { originalTotalAmmo: number; totalAmmo: number; consumed: number; - destroyed: boolean; + status: EquipmentStatus; } export interface AmmoControlGroupLocation { loc: string; quantity: number; - state: 'normal' | 'exposed' | 'destroyed'; + state: 'normal' | 'exposed' | 'disabled' | 'destroyed'; } export interface AmmoControlGroup { @@ -45,7 +46,7 @@ export interface AmmoControlGroup { locations: AmmoControlGroupLocation[]; totalAmmo: number; consumed: number; - destroyed: boolean; + status: EquipmentStatus; expandable: boolean; } @@ -116,7 +117,7 @@ export function getAmmoControlEntryForCriticalSlot(unit: CBTForceUnit, criticalS originalTotalAmmo: getOriginalTotalAmmo(unit, criticalSlot), totalAmmo, consumed: criticalSlot.consumed ?? 0, - destroyed: !unit.isEquipmentOperational(criticalSlot) + status: unit.getEquipmentStatus(criticalSlot) }; } @@ -141,10 +142,7 @@ function createInventoryAmmoControlEntry(unit: CBTForceUnit, inventoryEntry: Mou const totalAmmo = inventoryEntry.totalAmmo ?? originalTotalAmmo; const consumed = inventoryEntry.consumed ?? 0; const locationLabel = Array.from(inventoryEntry.locations ?? []).join('/') || 'Ammo'; - const destroyed = !unit.isEquipmentOperational(inventoryEntry) - || (isIntrinsicOneShotAmmoMount(inventoryEntry) - && !!inventoryEntry.parent - && !unit.isEquipmentOperational(inventoryEntry.parent)); + const status = getInventoryAmmoControlStatus(unit, inventoryEntry); return { id: `inventory:${inventoryEntry.id}`, owner: unit, @@ -158,10 +156,19 @@ function createInventoryAmmoControlEntry(unit: CBTForceUnit, inventoryEntry: Mou originalTotalAmmo, totalAmmo, consumed, - destroyed + status }; } +function getInventoryAmmoControlStatus(unit: CBTForceUnit, entry: MountedEquipment): EquipmentStatus { + return combineEquipmentStatuses([ + unit.getEquipmentStatus(entry), + ...(isIntrinsicOneShotAmmoMount(entry) && entry.parent + ? [unit.getEquipmentStatus(entry.parent)] + : []), + ]); +} + function ammoMatchesWeapon(weapon: WeaponEquipment, ammo: AmmoEquipment): boolean { if (weapon.ammoType === 'NA') return false; if (weapon.rackSize <= 0) return ammo.ammoType === weapon.ammoType; @@ -403,10 +410,14 @@ export function getAmmoControlEntriesForUnitWeapons(unit: CBTForceUnit, equipmen } export function getAmmoEntryRemaining(entry: AmmoControlEntry): number { - if (entry.destroyed) return 0; + if (!isAmmoControlEntryUsable(entry)) return 0; return Math.max(0, entry.totalAmmo - entry.consumed); } +export function isAmmoControlEntryUsable(entry: AmmoControlEntry): boolean { + return entry.status === 'available'; +} + export function getAmmoControlGroups(entries: AmmoControlEntry[]): AmmoControlGroup[] { const groups: AmmoControlGroup[] = []; const keyedGroups = new Map(); @@ -439,7 +450,7 @@ function createAmmoControlGroup(entries: AmmoControlEntry[]): AmmoControlGroup { displayName: firstEntry.displayName, totalAmmo: 0, consumed: 0, - destroyed: false, + status: 'available', expandable: false, locations: [], }; @@ -464,7 +475,8 @@ function isAmmoLocationExposed(entry: AmmoControlEntry, loc: string): boolean { } function getAmmoEntryLocationState(entry: AmmoControlEntry): AmmoControlGroupLocation['state'] { - if (entry.destroyed) return 'destroyed'; + if (entry.status === 'destroyed') return 'destroyed'; + if (entry.status === 'disabled') return 'disabled'; return isAmmoLocationExposed(entry, entry.locationLabel) ? 'exposed' : 'normal'; } @@ -493,13 +505,16 @@ function syncGroupTotals(group: AmmoControlGroup): void { group.locations = getAmmoControlGroupLocations(group.entries); group.totalAmmo = group.entries.reduce((total, entry) => total + entry.totalAmmo, 0); group.consumed = group.entries.reduce((total, entry) => total + entry.consumed, 0); - group.destroyed = group.entries.every(entry => entry.destroyed); + group.status = group.entries.some(entry => entry.status === 'available') + ? 'available' + : group.entries.some(entry => entry.status === 'disabled') ? 'disabled' : 'destroyed'; group.expandable = group.entries.length > 1; } function sortAmmoControlGroups(groups: AmmoControlGroup[]): AmmoControlGroup[] { return groups.sort((a, b) => { - if (a.destroyed !== b.destroyed) return a.destroyed ? 1 : -1; + const statusOrder: Record = { available: 0, disabled: 1, destroyed: 2 }; + if (a.status !== b.status) return statusOrder[a.status] - statusOrder[b.status]; const nameCompare = a.displayName.localeCompare(b.displayName); if (nameCompare !== 0) return nameCompare; return a.id.localeCompare(b.id); @@ -518,10 +533,7 @@ function syncEntryFromSource(entry: AmmoControlEntry, equipmentCatalog: Equipmen entry.originalTotalAmmo = getInventoryOriginalTotalAmmo(source); entry.totalAmmo = source.totalAmmo ?? entry.originalTotalAmmo; entry.consumed = source.consumed ?? 0; - entry.destroyed = !entry.owner.isEquipmentOperational(source) - || (isIntrinsicOneShotAmmoMount(source) - && !!source.parent - && !entry.owner.isEquipmentOperational(source.parent)); + entry.status = getInventoryAmmoControlStatus(entry.owner, source); return; } @@ -534,7 +546,7 @@ function syncEntryFromSource(entry: AmmoControlEntry, equipmentCatalog: Equipmen entry.originalTotalAmmo = getOriginalTotalAmmo(entry.owner, entry.source as CriticalSlot); entry.totalAmmo = getCriticalSlotTotalAmmo(entry.owner, entry.source as CriticalSlot); entry.consumed = (entry.source as CriticalSlot).consumed ?? 0; - entry.destroyed = !entry.owner.isEquipmentOperational(entry.source as CriticalSlot); + entry.status = entry.owner.getEquipmentStatus(entry.source as CriticalSlot); } function showAmmoToast(entry: AmmoControlEntry, deltaRemaining: number, context: HandlerCommandContext): void { @@ -557,7 +569,7 @@ function readAmmoToastDelta(context: HandlerCommandContext, toastId: string, del } export function changeAmmoEntryRemaining(entry: AmmoControlEntry, deltaRemaining: number, context: HandlerCommandContext): boolean { - if (entry.destroyed) return false; + if (!isAmmoControlEntryUsable(entry)) return false; const currentRemaining = getAmmoEntryRemaining(entry); const nextRemaining = clamp(currentRemaining + deltaRemaining, 0, entry.totalAmmo); const appliedDelta = nextRemaining - currentRemaining; @@ -578,11 +590,11 @@ export function changeAmmoEntriesRemaining(entries: AmmoControlEntry[], deltaRem while (remainingAdjustment > 0) { const target = deltaRemaining < 0 - ? reversedEntries.find(entry => !entry.destroyed && getAmmoEntryRemaining(entry) > 0) + ? reversedEntries.find(entry => isAmmoControlEntryUsable(entry) && getAmmoEntryRemaining(entry) > 0) : reversedEntries.find(entry => { const remaining = getAmmoEntryRemaining(entry); - return !entry.destroyed && remaining > 0 && remaining < entry.totalAmmo; - }) ?? sortedEntries.find(entry => !entry.destroyed && getAmmoEntryRemaining(entry) < entry.totalAmmo); + return isAmmoControlEntryUsable(entry) && remaining > 0 && remaining < entry.totalAmmo; + }) ?? sortedEntries.find(entry => isAmmoControlEntryUsable(entry) && getAmmoEntryRemaining(entry) < entry.totalAmmo); if (!target || !changeAmmoEntryRemaining(target, deltaRemaining < 0 ? -1 : 1, context)) break; changed = true; remainingAdjustment -= 1; @@ -626,7 +638,7 @@ function getTotalAmmoForAmmoType( } export async function setAmmoEntry(entry: AmmoControlEntry, context: HandlerCommandContext): Promise { - if (entry.destroyed) return false; + if (!isAmmoControlEntryUsable(entry)) return false; const equipmentRegistry = context.equipmentCatalog; const unitBlueprint = entry.owner.getUnit(); @@ -673,13 +685,14 @@ export async function setAmmoEntry(entry: AmmoControlEntry, context: HandlerComm export async function setAmmoGroup(group: AmmoControlGroup, context: HandlerCommandContext): Promise { if (group.entries.length === 1) return setAmmoEntry(group.entries[0], context); - if (group.destroyed) return false; + const editableEntries = group.entries.filter(isAmmoControlEntryUsable); + if (editableEntries.length === 0) return false; - const firstEntry = group.entries[0]; + const firstEntry = editableEntries[0]; const equipmentRegistry = context.equipmentCatalog; const unitBlueprint = firstEntry.owner.getUnit(); const inventory = firstEntry.owner.getInventory(); - const originalTotalAmmo = group.entries.reduce((total, entry) => total + entry.originalTotalAmmo, 0); + const originalTotalAmmo = editableEntries.reduce((total, entry) => total + entry.originalTotalAmmo, 0); const previousRemaining = getAmmoGroupRemaining(group); const compatibleAmmo = getCompatibleCatalogAmmo(firstEntry.originalAmmo, equipmentRegistry, unitBlueprint, inventory); @@ -690,7 +703,7 @@ export async function setAmmoGroup(group: AmmoControlGroup, context: HandlerComm originalTotalAmmo, ammoOptions: compatibleAmmo, quantity: previousRemaining, - maxQuantity: group.totalAmmo, + maxQuantity: editableEntries.reduce((total, entry) => total + entry.totalAmmo, 0), unitType: unitBlueprint.type, era: firstEntry.owner.force.era(), inventory, @@ -712,7 +725,7 @@ export async function setAmmoGroup(group: AmmoControlGroup, context: HandlerComm getTotalAmmoForAmmoType(firstEntry.originalAmmo, originalTotalAmmo, selectedAmmo, firstEntry.owner.gameRules, equipmentRegistry), ); - for (const entry of group.entries.sort(compareAmmoControlEntryOrder)) { + for (const entry of editableEntries.sort(compareAmmoControlEntryOrder)) { const newTotalAmmo = isIntrinsicOneShotAmmoMount(entry.source as MountedEquipment) ? entry.totalAmmo : getTotalAmmoForAmmoType(entry.originalAmmo, entry.originalTotalAmmo, selectedAmmo, entry.owner.gameRules, equipmentRegistry); diff --git a/src/app/utils/inventory-control-ammo.util.spec.ts b/src/app/utils/inventory-control-ammo.util.spec.ts index ab7785668..53cfe6937 100644 --- a/src/app/utils/inventory-control-ammo.util.spec.ts +++ b/src/app/utils/inventory-control-ammo.util.spec.ts @@ -116,6 +116,7 @@ describe('inventory-control ammo selection', () => { const owner = { getInventory: () => inventory, getCritSlots: () => [], + getEquipmentStatus: () => 'available' as const, isEquipmentOperational: () => true, } as unknown as CBTForceUnit; const mountedWeapon = new MountedWeapon({ owner, id: 'ac5', name: weapon.name, equipment: weapon }); @@ -198,6 +199,7 @@ describe('inventory-control ammo selection', () => { getInventory: () => inventory, getCritSlots: () => [], getUnit: () => createEmptyUnit({ subtype: 'Battle Armor' }), + getEquipmentStatus: () => 'available' as const, isEquipmentOperational: () => true, } as unknown as CBTForceUnit; const mountedWeapon = new MountedWeapon({ owner, id: 'lrm-os', name: weapon.internalName, equipment: weapon }); diff --git a/src/app/utils/inventory-control.util.ts b/src/app/utils/inventory-control.util.ts index a398c8693..46e016650 100644 --- a/src/app/utils/inventory-control.util.ts +++ b/src/app/utils/inventory-control.util.ts @@ -5,12 +5,12 @@ 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 type { CBTForceUnit, EquipmentAction } from '../models/cbt-force-unit.model'; import { MountedAmmo, MountedEquipment, MountedWeapon } from '../models/mounted-equipment.model'; import { parseInventoryComponentReference } from '../models/inventory-component-reference.model'; import { type CriticalSlot } from '../models/force-serialization'; import type { UnitComponent } from '../models/units.model'; -import type { InventoryControlRuntimeAmmoSelection, InventoryControlRuntimeEntryState, InventoryControlRuntimeRangeKey, InventoryControlRuntimeTarget, InventoryControlRuntimeTargetId } from '../models/inventory-control-runtime-state.model'; +import { resolveInventoryControlSelectedAmmoProfileId, type InventoryControlRuntimeAmmoSelection, type InventoryControlRuntimeEntryState, type InventoryControlRuntimeRangeKey, type InventoryControlRuntimeTarget, type InventoryControlRuntimeTargetId } from '../models/inventory-control-runtime-state.model'; import type { ToHitAdjustment, ToHitModifierBreakdownEntry, ToHitResolution } from '../models/rules/game-rules'; import { FIELD_GUN_LOCATION, InfantryRules } from '../models/rules/infantry-rules'; import { getBattleArmorTrooperNumber } from '../models/battle-armor-location.model'; @@ -142,7 +142,7 @@ interface AmmoSource { locationLabel: string; total: number; consumed: number; - destroyed: boolean; + status: EquipmentStatus; intrinsicOneShotAmmo: boolean; } @@ -192,15 +192,15 @@ export function setInventoryControlSortOrder(rows: InventoryControlRow[]): void const sortKey = inventoryControlSortKey(rows[0].category); rows.forEach((row, index) => { if (row.entry.setState(sortKey, index.toString())) { - row.entry.owner.setInventoryEntry(row.entry); + row.entry.owner.setInventoryEntry(row.entry, { phaseChange: false }); } }); } export function setInventoryControlMode(entry: MountedEquipment, mode: string): void { - entry.setState(INVENTORY_CONTROL_MODE_STATE, mode); + const changed = entry.setState(INVENTORY_CONTROL_MODE_STATE, mode); syncSvgMode(entry, mode); - entry.owner.setInventoryEntry(entry); + if (changed) entry.owner.setInventoryEntry(entry); } export function getInventoryControlGroups( @@ -235,6 +235,12 @@ export function isInventoryControlSelectableEntry(entry: MountedEquipment): bool return category === 'ranged' || category === 'physical'; } +/** The canonical action represented by an inventory-control entry. */ +export function inventoryControlEntryAction(entry: MountedEquipment): EquipmentAction { + if (entry.isPhysicalWeapon()) return 'physical-attack'; + return entry.equipment instanceof WeaponEquipment ? 'fire' : 'change-mode'; +} + export function selectInventoryControlEntry( unit: CBTForceUnit, entry: MountedEquipment, @@ -242,7 +248,7 @@ export function selectInventoryControlEntry( forceSelected = false ): boolean { if (!isInventoryControlSelectableEntry(entry) - || !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire')) return false; + || !entry.owner.canPerformEquipmentAction(entry, inventoryControlEntryAction(entry))) return false; const targets = unit.getInventoryControlTargets(); if (targets.length === 0) { @@ -303,7 +309,7 @@ export function resolveInventoryControlSelectedAmmoType( mode, false, ); - const profileId = resolveInventoryControlSelectedProfileId( + const profileId = resolveInventoryControlSelectedAmmoProfileId( candidates.profileOptions, selection?.selectedProfileId, selection?.preferredSourceOptionId, @@ -358,7 +364,7 @@ export function getInventoryControlAmmoSelectionCandidates( id: source.id, profileId: source.profileId, ammo: source.ammo, - usable: !resolveSourceUsability || (!source.destroyed && source.total > source.consumed), + usable: !resolveSourceUsability || (source.status === 'available' && source.total > source.consumed), })); const compatibleCatalogAmmo = intrinsicAmmo ? [] @@ -411,7 +417,7 @@ function getInventoryControlAmmoSummary( function createAmmoSummary(matchingAmmo: AmmoSource[]): InventoryControlAmmoSummary { const groupedAmmo = groupAmmoSources(matchingAmmo); - const availableAmmo = groupedAmmo.filter(source => !source.destroyed); + const availableAmmo = groupedAmmo.filter(source => source.status === 'available'); const locationSensitiveAmmoNames = getLocationSensitiveAmmoNames(groupedAmmo); return { @@ -423,10 +429,10 @@ function createAmmoSummary(matchingAmmo: AmmoSource[]): InventoryControlAmmoSumm profileId: source.profileId, label: formatAmmoOptionLabel(source, locationSensitiveAmmoNames.has(source.ammo.shortName)), ammo: source.ammo, - remaining: source.destroyed ? 0 : Math.max(0, source.total - source.consumed), + remaining: source.status === 'available' ? Math.max(0, source.total - source.consumed) : 0, total: source.total, - destroyed: source.destroyed, - disabled: source.destroyed + destroyed: source.status === 'destroyed', + disabled: source.status !== 'available' })) }; } @@ -457,21 +463,6 @@ export function resolveInventoryControlSelectedAmmoOption( return selectedProfile.find(option => !option.destroyed) ?? selectedProfile[0]; } -function resolveInventoryControlSelectedProfileId( - profileOptions: readonly { profileId: string }[], - selectedProfileId?: string | null, - preferredSourceOptionId?: string | null, - sourceOptions: readonly { id: string; profileId: string }[] = [], -): string | undefined { - const preferredSource = preferredSourceOptionId - ? sourceOptions.find(option => option.id === preferredSourceOptionId) - : undefined; - const requestedProfileId = selectedProfileId ?? preferredSource?.profileId; - return requestedProfileId && profileOptions.some(option => option.profileId === requestedProfileId) - ? requestedProfileId - : profileOptions[0]?.profileId; -} - function createInventoryControlAmmoProfileOptions( ammoCandidates: readonly AmmoEquipment[], ): InventoryControlAmmoProfileOption[] { @@ -484,11 +475,11 @@ function createInventoryControlAmmoProfileOptions( } function isUsableInventoryControlAmmoOption(option: InventoryControlAmmoOption): boolean { - return !option.destroyed && option.remaining > 0; + return !option.disabled && option.remaining > 0; } function groupAmmoSources(sources: AmmoSource[]): AmmoSource[] { - type GroupedAmmoSource = AmmoSource & { destroyedCount: number; sourceCount: number }; + type GroupedAmmoSource = AmmoSource & { availableCount: number; disabledCount: number; sourceCount: number }; const groups: GroupedAmmoSource[] = []; const groupMap = new Map(); @@ -497,13 +488,14 @@ function groupAmmoSources(sources: AmmoSource[]): AmmoSource[] { ? source.id : `${source.ammo.internalName}:${source.locationLabel}`; const existing = groupMap.get(key); - const remaining = source.destroyed ? 0 : Math.max(0, source.total - source.consumed); + const remaining = source.status === 'available' ? Math.max(0, source.total - source.consumed) : 0; if (!existing) { const groupedSource = { ...source, id: key, consumed: source.total - remaining, - destroyedCount: source.destroyed ? 1 : 0, + availableCount: source.status === 'available' ? 1 : 0, + disabledCount: source.status === 'disabled' ? 1 : 0, sourceCount: 1 }; groupMap.set(key, groupedSource); @@ -513,12 +505,15 @@ function groupAmmoSources(sources: AmmoSource[]): AmmoSource[] { existing.total += source.total; existing.consumed = Math.max(0, existing.consumed) + (source.total - remaining); - existing.destroyedCount = (existing.destroyedCount ?? 0) + (source.destroyed ? 1 : 0); + existing.availableCount += source.status === 'available' ? 1 : 0; + existing.disabledCount += source.status === 'disabled' ? 1 : 0; existing.sourceCount = (existing.sourceCount ?? 0) + 1; - existing.destroyed = existing.destroyedCount === existing.sourceCount; + existing.status = existing.availableCount > 0 + ? 'available' + : existing.disabledCount > 0 ? 'disabled' : 'destroyed'; } - return groups.map(({ destroyedCount, sourceCount, ...source }) => source); + return groups.map(({ availableCount, disabledCount, sourceCount, ...source }) => source); } function getLocationSensitiveAmmoNames(sources: AmmoSource[]): Set { @@ -536,7 +531,7 @@ function getLocationSensitiveAmmoNames(sources: AmmoSource[]): Set { } function formatAmmoOptionLabel(source: AmmoSource, showLocation: boolean): string { - const remaining = source.destroyed ? 0 : Math.max(0, source.total - source.consumed); + const remaining = source.status === 'available' ? Math.max(0, source.total - source.consumed) : 0; const location = showLocation ? `[${source.locationLabel}] ` : ''; return `${location}${source.ammo.shortName} (${remaining}/${source.total})`; } @@ -593,7 +588,7 @@ function buildInventoryControlRow( const status = entry.owner.getEquipmentStatus(entry); const hitModifierBreakdown = unitRules.getEquipmentToHitModifiers(entry); const destroyed = options.destroyed ?? status === 'destroyed'; - const disabled = !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire') + const disabled = !entry.owner.canPerformEquipmentAction(entry, inventoryControlEntryAction(entry)) || status === 'disabled' || rules.isSelectable?.(entry) === false; const category = getEntryCategory(entry); @@ -947,7 +942,7 @@ function createCriticalSlotAmmoSource( locationLabel: criticalSlot.loc ?? 'Ammo', total: criticalSlot.totalAmmo || elementTotal || 0, consumed: criticalSlot.consumed ?? 0, - destroyed: resolveAvailability && !unit.isEquipmentOperational(criticalSlot), + status: resolveAvailability ? unit.getEquipmentStatus(criticalSlot) : 'available', intrinsicOneShotAmmo: false, }; } @@ -974,14 +969,21 @@ function createInventoryAmmoSource( locationLabel, total, consumed: entry.consumed ?? 0, - destroyed: resolveAvailability && (!entry.owner.isEquipmentOperational(entry) - || (isIntrinsicOneShotAmmoMount(entry) - && !!entry.parent - && !entry.owner.isEquipmentOperational(entry.parent))), + status: resolveInventoryAmmoSourceStatus(entry, resolveAvailability), intrinsicOneShotAmmo: isIntrinsicOneShotAmmoMount(entry), }; } +function resolveInventoryAmmoSourceStatus(entry: MountedEquipment, resolveAvailability: boolean): EquipmentStatus { + if (!resolveAvailability) return 'available'; + return combineEquipmentStatuses([ + entry.owner.getEquipmentStatus(entry), + ...(isIntrinsicOneShotAmmoMount(entry) && entry.parent + ? [entry.owner.getEquipmentStatus(entry.parent)] + : []), + ]); +} + function getInventoryOriginalTotalAmmo(entry: MountedAmmo): number { return entry.originalTotalAmmo ?? entry.totalAmmo ?? entry.getMaxShots(); } @@ -1141,7 +1143,7 @@ export function formatHitModifier(hitModifier: number | 'Vs' | '*' | null): stri export function syncSvgMode( entry: MountedEquipment, mode: string | null, - disabled = !entry.owner.canPerformEquipmentAction(entry, entry.isPhysicalWeapon() ? 'physical-attack' : 'fire') + disabled = !entry.owner.canPerformEquipmentAction(entry, inventoryControlEntryAction(entry)) ): void { const el = entry.el; if (!el) return; From 0acbd0489949dc10f903a1b65d37d2756b5b6255 Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 9 Aug 2026 10:52:34 +0200 Subject: [PATCH 10/12] test --- .../page-psr-warning-panel.component.spec.ts | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.spec.ts b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.spec.ts index 5a648e6d3..aaa0cead9 100644 --- a/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.spec.ts +++ b/src/app/components/page-viewer/overlay/page-psr-warning-panel.component.spec.ts @@ -6,6 +6,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { DiceRollerComponent } from '../../dice-roller/dice-roller.component'; import { OverlayManagerService } from '../../../services/overlay-manager.service'; +import type { PSRCheck } from '../../../models/rules/unit-type-rules'; import { PageInteractionOverlayComponent } from './page-interaction-overlay.component'; import { PagePsrWarningPanelComponent, psrRollOutcome } from './page-psr-warning-panel.component'; @@ -92,4 +93,52 @@ describe('PagePsrWarningPanelComponent', () => { ]); expect(new Set(Array.from(modifierLocations, location => location.getBoundingClientRect().width)).size).toBe(1); }); -}); \ No newline at end of file + + it('retains a resolved rule check long enough to display its outcome', () => { + const check: PSRCheck = { + fallCheck: 0, + reason: 'RISC emergency shutdown', + failureOutcome: 'Shutdown', + resolution: { key: 'risc-shutdown', token: 'token-1' }, + }; + let checks: PSRCheck[] = [check]; + let status: 'pending' | 'success' | 'failed' = 'pending'; + const resolveRuleCheck = jasmine.createSpy('resolveRuleCheck').and.callFake( + (_key: string, _token: string, result: 'success' | 'failed') => { + status = result; + checks = []; + return true; + }, + ); + const turnState = { + getPSRChecks: () => checks, + getPSROutcome: () => undefined, + resolvePSRCheck: jasmine.createSpy('resolvePSRCheck'), + autoFall: () => false, + }; + const unit = { + id: 'unit-1', + rules: { controlRollFullLabel: 'Piloting Skill Rolls' }, + turnState: () => turnState, + PSRTargetRoll: () => 8, + PSRModifiers: () => ({ modifiers: [] }), + resolveRuleCheck, + getRuleCheck: () => ({ token: 'token-1', status }), + }; + + TestBed.configureTestingModule({ + imports: [PagePsrWarningPanelComponent], + providers: [ + { provide: PageInteractionOverlayComponent, useValue: { unit: signal(unit) } }, + { provide: OverlayManagerService, useValue: { closeManagedOverlay: jasmine.createSpy('closeManagedOverlay') } }, + ], + }); + const fixture = TestBed.createComponent(PagePsrWarningPanelComponent); + + fixture.componentInstance.resolve(check, 'success'); + + expect(resolveRuleCheck).toHaveBeenCalledOnceWith('risc-shutdown', 'token-1', 'success'); + expect(fixture.componentInstance.psrChecks()).toEqual([check]); + expect(fixture.componentInstance.outcome(check)).toBe('success'); + }); +}); From 21fd5a31ce17bb1c22f719267eb80a703c3a79b4 Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 9 Aug 2026 11:45:13 +0200 Subject: [PATCH 11/12] dirty state from state change --- .../weapons-equipment-panel.component.spec.ts | 6 +- .../bombast-laser.handler.spec.ts | 54 ++++++++++++++ .../bombast-laser.handler.ts | 1 + .../ppc-capacitor.handler.spec.ts | 33 +++++++-- .../ppc-capacitor.handler.ts | 1 + src/app/models/cbt-force-unit.model.spec.ts | 72 +++++++++++++------ src/app/models/cbt-force-unit.model.ts | 4 +- src/app/testing/unit-test-helpers.ts | 2 +- src/app/utils/inventory-control.util.ts | 2 +- 9 files changed, 140 insertions(+), 35 deletions(-) diff --git a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts index b7a8ab961..2b3d9bed1 100644 --- a/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts +++ b/src/app/components/equipment-dialog/weapons-equipment-panel.component.spec.ts @@ -1254,9 +1254,9 @@ describe('WeaponsEquipmentPanelComponent', () => { const rangedSortKey = inventoryControlSortKey('ranged'); expect(first.states.get(rangedSortKey)).toBe('1'); expect(second.states.get(rangedSortKey)).toBe('0'); - expect(setInventoryEntry).toHaveBeenCalledWith(first, { phaseChange: false }); - expect(setInventoryEntry).toHaveBeenCalledWith(second, { phaseChange: false }); - expect(setInventoryEntry).toHaveBeenCalledWith(modeEntry, { phaseChange: false }); + expect(setInventoryEntry).toHaveBeenCalledWith(first); + expect(setInventoryEntry).toHaveBeenCalledWith(second); + expect(setInventoryEntry).toHaveBeenCalledWith(modeEntry); setInventoryEntry.calls.reset(); const row = component.groups().find(candidate => candidate.id === 'ranged')!.rows.find(candidate => candidate.id === 'mode')!; diff --git a/src/app/equipment-handlers/bombast-laser.handler.spec.ts b/src/app/equipment-handlers/bombast-laser.handler.spec.ts index c116e8f18..f227f109d 100644 --- a/src/app/equipment-handlers/bombast-laser.handler.spec.ts +++ b/src/app/equipment-handlers/bombast-laser.handler.spec.ts @@ -35,10 +35,18 @@ import { function owner(gameRules: CBTGameRules = CORE_2026_GAME_RULES) { const { owner } = createTestEquipmentOwner({ gameRules }); + const markEquipmentStateChanged = jasmine.createSpy('markEquipmentStateChanged'); + Object.assign(owner, { + turnState: () => ({ markEquipmentStateChanged }), + }); spyOn(owner, 'setInventoryEntry').and.callThrough(); return owner; } +function equipmentStateChangeMarker(entry: MountedEquipment): jasmine.Spy { + return entry.owner.turnState().markEquipmentStateChanged as jasmine.Spy; +} + function bombastLaser( gameRules: CBTGameRules = CORE_2026_GAME_RULES, states = new Map(), @@ -241,6 +249,52 @@ describe('BombastLaserHandler', () => { })); }); + it('marks the phase dirty exactly once when charging begins', () => { + const entry = bombastLaser(); + const markEquipmentStateChanged = equipmentStateChangeMarker(entry); + + select(handler, entry, BOMBAST_LASER_CHARGING_STATE); + + expect(markEquipmentStateChanged).toHaveBeenCalledTimes(1); + expect(entry.owner.setInventoryEntry).toHaveBeenCalledOnceWith(entry); + }); + + it('does not mark the phase dirty for a repeated or rejected charge request', () => { + const charging = bombastLaser(CORE_2026_GAME_RULES, new Map([ + [BOMBAST_LASER_CHARGE_STATE_KEY, BOMBAST_LASER_CHARGING_STATE] + ])); + const chargingMarker = equipmentStateChangeMarker(charging); + + select(handler, charging, BOMBAST_LASER_CHARGING_STATE); + + expect(chargingMarker).not.toHaveBeenCalled(); + expect(charging.owner.setInventoryEntry).not.toHaveBeenCalled(); + + const fired = bombastLaser(CORE_2026_GAME_RULES, new Map([ + [BOMBAST_LASER_FIRED_STATE_KEY, '1'] + ])); + const firedMarker = equipmentStateChangeMarker(fired); + + select(handler, fired, BOMBAST_LASER_CHARGING_STATE); + + expect(firedMarker).not.toHaveBeenCalled(); + expect(fired.owner.setInventoryEntry).not.toHaveBeenCalled(); + }); + + it('does not explicitly mark mode, discharge, fire, or end-turn state changes', () => { + const entry = bombastLaser(CORE_2026_GAME_RULES, new Map([ + [BOMBAST_LASER_CHARGE_STATE_KEY, BOMBAST_LASER_CHARGED_STATE] + ])); + const markEquipmentStateChanged = equipmentStateChangeMarker(entry); + + select(handler, entry, BOMBAST_LASER_DAMAGE_16_MODE); + select(handler, entry, 'discharged'); + handler.afterInventoryControlFire(entry); + handler.onEndTurn(entry); + + expect(markEquipmentStateChanged).not.toHaveBeenCalled(); + }); + it('can begin charged, gains X, and discharges after firing', () => { const entry = bombastLaser(CORE_2026_GAME_RULES, new Map([ [BOMBAST_LASER_CHARGE_STATE_KEY, BOMBAST_LASER_CHARGED_STATE] diff --git a/src/app/equipment-handlers/bombast-laser.handler.ts b/src/app/equipment-handlers/bombast-laser.handler.ts index c42a74a14..70283de19 100644 --- a/src/app/equipment-handlers/bombast-laser.handler.ts +++ b/src/app/equipment-handlers/bombast-laser.handler.ts @@ -111,6 +111,7 @@ export class BombastLaserHandler extends EquipmentInteractionHandler { } if (setBombastLaserChargeState(equipment, BOMBAST_LASER_CHARGING_STATE)) { equipment.owner.setInventoryEntry(equipment); + equipment.owner.turnState().markEquipmentStateChanged(); } context.toastService.showToast('Bombast Laser charging', 'info'); return true; diff --git a/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts b/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts index 6bc7d8246..b316bbcc5 100644 --- a/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts +++ b/src/app/equipment-handlers/ppc-capacitor.handler.spec.ts @@ -27,6 +27,10 @@ import { function setup(destroyed = false, compatible = true) { const fixture = createTestEquipmentOwner(); const { owner } = fixture; + const markEquipmentStateChanged = jasmine.createSpy('markEquipmentStateChanged'); + Object.assign(owner, { + turnState: () => ({ markEquipmentStateChanged }), + }); const capacitor = new MountedEquipment({ owner, id: 'capacitor', @@ -56,7 +60,7 @@ function setup(destroyed = false, compatible = true) { linkedWith: [capacitor] }); fixture.inventory.push(weapon, capacitor); - return { ...fixture, weapon, capacitor }; + return { ...fixture, weapon, capacitor, markEquipmentStateChanged }; } function setupWithCriticalSlots() { @@ -196,7 +200,7 @@ describe('PpcCapacitorHandler', () => { }); it('charges for one turn, blocks firing, and becomes charged at end turn', () => { - const { weapon, capacitor } = setup(); + const { weapon, capacitor, markEquipmentStateChanged } = setup(); handler.handleSelection(weapon, { value: PPC_CAPACITOR_CHARGING_STATE } as PickerChoice, commandContext); @@ -205,15 +209,34 @@ describe('PpcCapacitorHandler', () => { expect(handler.getInventoryHeatSources(weapon, {} as never, queryContext)[0]).toEqual(jasmine.objectContaining({ value: 5 })); expect(handler.applyInventoryControlHeatEffects(weapon, { value: 5, weakened: false }, queryContext)) .toEqual({ value: 5, weakened: false }); + expect(markEquipmentStateChanged).toHaveBeenCalledTimes(1); handler.onEndTurn(weapon); expect(capacitor.states.get(PPC_CAPACITOR_STATE_KEY)).toBe(PPC_CAPACITOR_CHARGED_STATE); expect(handler.isInventoryControlSelectable(weapon, queryContext)).toBeNull(); + expect(markEquipmentStateChanged).toHaveBeenCalledTimes(1); + }); + + it('does not mark rejected, repeated, or discharge transitions as phase changes', () => { + const { weapon, capacitor, markEquipmentStateChanged } = setup(); + + capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGING_STATE); + handler.handleSelection(weapon, { value: PPC_CAPACITOR_CHARGING_STATE } as PickerChoice, commandContext); + expect(markEquipmentStateChanged).not.toHaveBeenCalled(); + + handler.handleSelection(weapon, { value: 'discharged' } as PickerChoice, commandContext); + expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + expect(markEquipmentStateChanged).not.toHaveBeenCalled(); + + capacitor.states.set(PPC_CAPACITOR_FIRED_STATE_KEY, '1'); + handler.handleSelection(weapon, { value: PPC_CAPACITOR_CHARGING_STATE } as PickerChoice, commandContext); + expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + expect(markEquipmentStateChanged).not.toHaveBeenCalled(); }); it('discharges and marks the capacitor fired after firing', () => { - const { weapon, capacitor, inventoryWrites } = setup(); + const { weapon, capacitor, inventoryWrites, markEquipmentStateChanged } = setup(); capacitor.states.set(PPC_CAPACITOR_STATE_KEY, PPC_CAPACITOR_CHARGED_STATE); handler.afterInventoryControlFire(weapon); @@ -221,6 +244,7 @@ describe('PpcCapacitorHandler', () => { expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); expect(capacitor.states.get(PPC_CAPACITOR_FIRED_STATE_KEY)).toBe('1'); expect(inventoryWrites).toEqual([capacitor]); + expect(markEquipmentStateChanged).not.toHaveBeenCalled(); }); it('discharges an unavailable capacitor after its linked PPC fires', () => { @@ -237,7 +261,7 @@ describe('PpcCapacitorHandler', () => { for (const state of [PPC_CAPACITOR_CHARGING_STATE, PPC_CAPACITOR_CHARGED_STATE] as const) { for (const hitEntry of ['PPC', 'capacitor'] as const) { it(`explodes both direct-inventory mounts when a ${state} ${hitEntry} hit is committed`, () => { - const { weapon, capacitor } = setup(); + const { weapon, capacitor, markEquipmentStateChanged } = setup(); capacitor.states.set(PPC_CAPACITOR_STATE_KEY, state); (hitEntry === 'PPC' ? weapon : capacitor).setPendingDestroyed(true); @@ -251,6 +275,7 @@ describe('PpcCapacitorHandler', () => { expect(weapon.committedDestroyed()).toBeTrue(); expect(capacitor.committedDestroyed()).toBeTrue(); expect(capacitor.states.has(PPC_CAPACITOR_STATE_KEY)).toBeFalse(); + expect(markEquipmentStateChanged).not.toHaveBeenCalled(); }); } } diff --git a/src/app/equipment-handlers/ppc-capacitor.handler.ts b/src/app/equipment-handlers/ppc-capacitor.handler.ts index 8d1797fa8..2bc9b4ba3 100644 --- a/src/app/equipment-handlers/ppc-capacitor.handler.ts +++ b/src/app/equipment-handlers/ppc-capacitor.handler.ts @@ -65,6 +65,7 @@ export class PpcCapacitorHandler extends EquipmentInteractionHandler { } if (setPpcCapacitorState(capacitor, charging ? PPC_CAPACITOR_CHARGING_STATE : null)) { capacitor.owner.setInventoryEntry(capacitor); + if (charging) capacitor.owner.turnState().markEquipmentStateChanged(); } context.toastService.showToast(`PPC Capacitor ${charging ? 'charging' : 'discharged'}`, 'info'); return true; diff --git a/src/app/models/cbt-force-unit.model.spec.ts b/src/app/models/cbt-force-unit.model.spec.ts index 4943343f7..bf3353cb8 100644 --- a/src/app/models/cbt-force-unit.model.spec.ts +++ b/src/app/models/cbt-force-unit.model.spec.ts @@ -20,7 +20,7 @@ import { UnitSvgMekService } from '../services/unit-svg-mek.service'; import { UnitSvgAeroService } from '../services/unit-svg-aero.service'; import { createEmptyUnit } from '../testing/unit-test-helpers'; import type { Unit } from './units.model'; -import { EquipmentInteractionHandler, EquipmentInteractionRegistryService } from '../services/equipment-interaction-registry.service'; +import { createHandlerCommandContext, createHandlerQueryContext, EquipmentInteractionHandler, EquipmentInteractionRegistryService } from '../services/equipment-interaction-registry.service'; import { LaserInsulatorHandler } from '../equipment-handlers/laser-insulator.handler'; import { RISC_LASER_PULSE_MODE, RiscLaserPulseModuleHandler } from '../equipment-handlers/risc-laser-pulse-module.handler'; import { DialogsService } from '../services/dialogs.service'; @@ -2174,7 +2174,7 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.turnState().moveDistance()).toBe(8); }); - it('keeps the phase dirty after an equipment state change until phase end', () => { + it('keeps ordinary equipment state persistence outside the phase lifecycle', () => { const forceUnit = createForceUnit(createVehicleUnit(equipment)); initialize(forceUnit); const entry = forceUnit.getInventory().find(item => item.equipment instanceof WeaponEquipment)!; @@ -2182,26 +2182,11 @@ describe('CBTForceUnit direct inventory ammo bins', () => { entry.setState('test-mode', 'charged'); forceUnit.setInventoryEntry(entry); - expect(forceUnit.turnState().dirtyPhase()).toBeTrue(); - expect(forceUnit.turnState().serialize()?.equipmentStateChanged).toBeTrue(); - - const restored = CBTForceUnit.deserialize( - forceUnit.serialize(), - new TestCBTForce('Restored Equipment State Force', dataService, unitInitializer, injector), - dataService, - unitInitializer, - injector, - ); - - expect(restored.turnState().dirtyPhase()).toBeTrue(); - - forceUnit.endPhase(); - expect(forceUnit.turnState().dirtyPhase()).toBeFalse(); expect(forceUnit.turnState().serialize()?.equipmentStateChanged).toBeUndefined(); }); - it('keeps the phase dirty after changing ammo stored in a critical slot', () => { + it('keeps ammo type changes in a critical slot outside the phase lifecycle', () => { const forceUnit = createForceUnit(createMekUnit()); initialize(forceUnit); const ammo = equipment['Clan Ultra AC/20 Ammo'] as AmmoEquipment; @@ -2218,13 +2203,14 @@ describe('CBTForceUnit direct inventory ammo bins', () => { expect(forceUnit.turnState().dirtyPhase()).toBeFalse(); - ammoSlot.consumed = 1; + const precisionAmmo = equipment['Clan Ultra AC/20 Precision Ammo'] as AmmoEquipment; + ammoSlot.originalName = ammoSlot.name; + ammoSlot.name = precisionAmmo.internalName; + ammoSlot.eq = precisionAmmo; + ammoSlot.totalAmmo = 4; forceUnit.setCritSlot(ammoSlot); - expect(forceUnit.turnState().dirtyPhase()).toBeTrue(); - - forceUnit.endPhase(); - + expect(forceUnit.getCritSlot('LT', 0)?.eq).toBe(precisionAmmo); expect(forceUnit.turnState().dirtyPhase()).toBeFalse(); }); @@ -2404,6 +2390,46 @@ describe('CBTForceUnit direct inventory ammo bins', () => { return { weapon, capacitor, weaponSlots, capacitorSlots, unrelatedSlot }; } + it('keeps a declared PPC capacitor charge dirty through serialization until phase end', async () => { + const forceUnit = createForceUnit(createVehicleUnit(equipment)); + initialize(forceUnit); + const { weapon, capacitor } = installChargedPpcPair(forceUnit); + capacitor.deleteState(PPC_CAPACITOR_STATE_KEY); + forceUnit.setInventoryEntry(capacitor); + expect(forceUnit.turnState().dirtyPhase()).toBeFalse(); + + const registry = TestBed.inject(EquipmentInteractionRegistryService).getRegistry(); + const equipmentRegistry = dataService.getEquipmentRegistry(); + const choice = registry.getChoices(weapon, createHandlerQueryContext(equipmentRegistry)) + .find(candidate => candidate.value === PPC_CAPACITOR_CHARGING_STATE)!; + await registry.handleSelection(weapon, choice, createHandlerCommandContext( + equipmentRegistry, + TestBed.inject(ToastService), + TestBed.inject(DialogsService), + )); + + expect(capacitor.states.get(PPC_CAPACITOR_STATE_KEY)).toBe(PPC_CAPACITOR_CHARGING_STATE); + expect(forceUnit.turnState().dirtyPhase()).toBeTrue(); + expect(forceUnit.turnState().serialize()?.equipmentStateChanged).toBeTrue(); + + const restored = CBTForceUnit.deserialize( + forceUnit.serialize(), + new TestCBTForce('Restored PPC Charge Force', dataService, unitInitializer, injector), + dataService, + unitInitializer, + injector, + ); + expect(restored.turnState().dirtyPhase()).toBeTrue(); + expect(restored.getInventory().find(entry => entry.id === capacitor.id)?.states.get(PPC_CAPACITOR_STATE_KEY)) + .toBe(PPC_CAPACITOR_CHARGING_STATE); + + forceUnit.endPhase(); + + expect(forceUnit.turnState().dirtyPhase()).toBeFalse(); + expect(forceUnit.turnState().serialize()?.equipmentStateChanged).toBeUndefined(); + expect(capacitor.states.get(PPC_CAPACITOR_STATE_KEY)).toBe(PPC_CAPACITOR_CHARGING_STATE); + }); + it('commits a charged PPC-capacitor explosion for direct inventory at phase end', () => { const forceUnit = createForceUnit(createVehicleUnit(equipment)); initialize(forceUnit); diff --git a/src/app/models/cbt-force-unit.model.ts b/src/app/models/cbt-force-unit.model.ts index 8a3cb9914..4d4e2f453 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -442,7 +442,6 @@ export class CBTForceUnit extends ForceUnit { } setCritSlot(slot: CriticalSlot) { - this.turnState().markEquipmentStateChanged(); const crits = [...this.state.crits()]; const existingIndex = crits.findIndex(c => c.loc === slot.loc && c.slot === slot.slot); if (existingIndex !== -1) { @@ -510,8 +509,7 @@ export class CBTForceUnit extends ForceUnit { } } - setInventoryEntry(inventoryEntry: MountedEquipment, options: { phaseChange?: boolean } = {}) { - if (options.phaseChange !== false) this.turnState().markEquipmentStateChanged(); + setInventoryEntry(inventoryEntry: MountedEquipment) { const inventory = [...this.state.inventory()]; const existingIndex = inventory.findIndex(item => item.id === inventoryEntry.id); if (existingIndex !== -1) { diff --git a/src/app/testing/unit-test-helpers.ts b/src/app/testing/unit-test-helpers.ts index 9af2edf1e..86758a9b2 100644 --- a/src/app/testing/unit-test-helpers.ts +++ b/src/app/testing/unit-test-helpers.ts @@ -617,7 +617,7 @@ export function createTestEquipmentOwner(options: TestEquipmentOwnerOptions = {} getUnit: () => unit, getCondition: (condition: string) => conditions.has(condition), getInventory: () => inventory, - setInventoryEntry: (entry: MountedEquipment, _options: { phaseChange?: boolean } = {}) => { + setInventoryEntry: (entry: MountedEquipment) => { const existingIndex = inventory.findIndex(candidate => candidate.id === entry.id); if (existingIndex === -1) inventory.push(entry); else inventory[existingIndex] = entry; diff --git a/src/app/utils/inventory-control.util.ts b/src/app/utils/inventory-control.util.ts index 46e016650..9f51a26d6 100644 --- a/src/app/utils/inventory-control.util.ts +++ b/src/app/utils/inventory-control.util.ts @@ -192,7 +192,7 @@ export function setInventoryControlSortOrder(rows: InventoryControlRow[]): void const sortKey = inventoryControlSortKey(rows[0].category); rows.forEach((row, index) => { if (row.entry.setState(sortKey, index.toString())) { - row.entry.owner.setInventoryEntry(row.entry, { phaseChange: false }); + row.entry.owner.setInventoryEntry(row.entry); } }); } From c0980ebd9cc711c24a5dc8fcdd1fdb37e3293161 Mon Sep 17 00:00:00 2001 From: exeea Date: Sun, 9 Aug 2026 11:47:48 +0200 Subject: [PATCH 12/12] test fix --- src/app/testing/unit-test-helpers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/testing/unit-test-helpers.ts b/src/app/testing/unit-test-helpers.ts index 86758a9b2..2e7dd9088 100644 --- a/src/app/testing/unit-test-helpers.ts +++ b/src/app/testing/unit-test-helpers.ts @@ -205,6 +205,7 @@ export interface CBTForceUnitTestTurnState { heatDissipationBalance(): number; effectiveHeatDissipation(): number; addFiredHeat(amount: number): void; + markEquipmentStateChanged(): void; } export class CBTForceUnitTestHarness { @@ -281,7 +282,8 @@ export class CBTForceUnitTestHarness { effectiveHeatDissipation: () => Math.max(0, heatDissipationBalance()), addFiredHeat: (amount: number) => { if (Number.isFinite(amount) && amount > 0) firedHeat += amount; - } + }, + markEquipmentStateChanged: () => {}, }; const findCurrentCriticalSlot = (snapshot: CriticalSlot): CriticalSlot | null => {