diff --git a/scripts/load-single-unit.ts b/scripts/load-single-unit.ts index 10dad17ab..316f5a35d 100644 --- a/scripts/load-single-unit.ts +++ b/scripts/load-single-unit.ts @@ -184,7 +184,6 @@ function main() { console.log(` location=${locations} size=${mount.size ?? 1} tonnage=${tonnage === undefined ? '' : formatDiagnosticNumber(tonnage)} rear=${mount.rearMounted} omni=${mount.omniPodMounted}`); console.log(` cost=${cost === undefined ? '' : formatDiagnosticNumber(cost)} BV=${formatDiagnosticNumber(bv)}`); if (linked || linking) console.log(` linked=${linked ?? '-'} linking=${linking ?? '-'}`); - if (mount.secondEquipment) console.log(` paired=${mount.secondEquipment.name}`); } console.log(`\n${'═'.repeat(104)}`); @@ -211,13 +210,13 @@ function main() { console.log(`\nEquipment:`); for (const m of entity.equipment()) { const locs = m.placements?.map(p => `${p.location}:${p.slotIndex}`) ?? [m.location]; - const criticalSlots = m.equipment?.getNumCriticalSlots(entity, m.size ?? 0); + const criticalSlots = m.getCriticalSlotRequirement(entity); console.log(` ${m.equipmentId}`); console.log(` locations: [${locs.join(', ')}] crits: ${criticalSlots ?? '-'}`); if (m.rearMounted) console.log(` rear-mounted`); if (m.omniPodMounted) console.log(` omnipod`); if (m.armored) console.log(` armored`); - if (m.isSplit) console.log(` split`); + if (m.isSplitAcrossLocations) console.log(` split`); if (m.size != null) console.log(` size: ${m.size}`); } // ── Critical Slot Grid (3-column layout) ── @@ -255,7 +254,7 @@ function main() { if (s.type === 'empty') label = '-Empty-'; else if (s.type === 'system') label = s.systemType ?? 'System'; else { - label = s.mount.equipmentId; + label = s.mounts.map(mount => mount.equipmentId).join(' | '); } const flags = [s.armored ? '(A)' : '', s.omniPod ? '(O)' : ''].filter(Boolean).join(''); return `${String(i + 1).padStart(2)}. ${label}${flags ? ' ' + flags : ''}`; diff --git a/src/app/components/page-viewer/internal/page-viewer-option-reaction.service.spec.ts b/src/app/components/page-viewer/internal/page-viewer-option-reaction.service.spec.ts index 19525c0d5..56870f497 100644 --- a/src/app/components/page-viewer/internal/page-viewer-option-reaction.service.spec.ts +++ b/src/app/components/page-viewer/internal/page-viewer-option-reaction.service.spec.ts @@ -53,13 +53,19 @@ describe('PageViewerOptionReactionService', () => { })).toBeFalse(); }); - it('requests redisplay only when transitioning from read-only to editable', () => { + it('requests redisplay for either read-only ownership transition', () => { expect(service.shouldRedisplayForReadOnlyChange({ - isReadOnly: true, + isReadOnly: false, viewInitialized: true, isSwiping: false })).toBeFalse(); + expect(service.shouldRedisplayForReadOnlyChange({ + isReadOnly: true, + viewInitialized: true, + isSwiping: false + })).toBeTrue(); + expect(service.shouldRedisplayForReadOnlyChange({ isReadOnly: false, viewInitialized: true, diff --git a/src/app/components/page-viewer/internal/page-viewer-option-reaction.service.ts b/src/app/components/page-viewer/internal/page-viewer-option-reaction.service.ts index 3a7da67b2..972c1a6c6 100644 --- a/src/app/components/page-viewer/internal/page-viewer-option-reaction.service.ts +++ b/src/app/components/page-viewer/internal/page-viewer-option-reaction.service.ts @@ -37,7 +37,7 @@ export class PageViewerOptionReactionService { return false; } - const shouldRedisplay = this.previousReadOnly && !isReadOnly && viewInitialized && !isSwiping; + const shouldRedisplay = this.previousReadOnly !== isReadOnly && viewInitialized && !isSwiping; this.previousReadOnly = isReadOnly; return shouldRedisplay; } 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 22a23aacc..85caf1fbc 100644 --- a/src/app/components/page-viewer/svg-interaction.service.spec.ts +++ b/src/app/components/page-viewer/svg-interaction.service.spec.ts @@ -379,6 +379,23 @@ describe('SvgInteractionService', () => { expect(pageViewerState.inventoryDialogOpen()).toBeFalse(); }); + it('marks read-only sheets to hide condition buttons and restores editable sheets', () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.innerHTML = ''; + const unit = createSvgInteractionUnit({ + id: 'unit-a', + getUnit: () => ({ type: 'Mek' }), + }); + + service.setupReadOnlyInteractions(svg); + expect(svg.classList.contains('read-only')).toBeTrue(); + expect(svg.querySelector('.unitConditionButton')?.classList.contains('edit-only')).toBeTrue(); + + service.updateUnit(unit); + service.setupInteractions(svg); + expect(svg.classList.contains('read-only')).toBeFalse(); + }); + it('shows equipment handler choices for mounted equipment crit slots', async () => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.innerHTML = 'Active Probe'; diff --git a/src/app/components/page-viewer/svg-interaction.service.ts b/src/app/components/page-viewer/svg-interaction.service.ts index 2f65ec1c0..9149d4915 100644 --- a/src/app/components/page-viewer/svg-interaction.service.ts +++ b/src/app/components/page-viewer/svg-interaction.service.ts @@ -238,6 +238,8 @@ export class SvgInteractionService { if (this.interactionAbortController) { this.interactionAbortController.abort(); } + svg.classList.remove('read-only'); + this.markEditOnlyControls(svg); this.interactionAbortController = new AbortController(); const signal = this.interactionAbortController.signal; this.setupPipInteractions(svg, signal); @@ -261,11 +263,17 @@ export class SvgInteractionService { if (this.interactionAbortController) { this.interactionAbortController.abort(); } + svg.classList.add('read-only'); + this.markEditOnlyControls(svg); this.interactionAbortController = new AbortController(); const signal = this.interactionAbortController.signal; this.setupAmmoProfileInteractions(svg, signal); } + private markEditOnlyControls(svg: SVGSVGElement): void { + svg.querySelectorAll('.unitConditionButton').forEach(control => control.classList.add('edit-only')); + } + private addSvgTapHandler( el: SVGElement, handler: (evt: PointerEvent, primaryAction: boolean) => void, diff --git a/src/app/models/cbt-force-unit.model.ts b/src/app/models/cbt-force-unit.model.ts index a8d85afe2..3c99896fc 100644 --- a/src/app/models/cbt-force-unit.model.ts +++ b/src/app/models/cbt-force-unit.model.ts @@ -889,12 +889,10 @@ export class CBTForceUnit extends ForceUnit { if (crit.eq instanceof AmmoEquipment && crit.originalName && crit.originalName !== crit.name) { const originalAmmo = equipmentList[crit.originalName] as AmmoEquipment | undefined; if (originalAmmo) { - const originalBv = originalAmmo.bv; - const currentBv = crit.eq.bv; - if (originalBv === "variable" || currentBv === "variable") { + if (!originalAmmo.hasFixedBV() || !crit.eq.hasFixedBV()) { continue; // Skip variable BV. TODO: need to be handle when we have BaseEntity } - bvVariation += currentBv - originalBv; + bvVariation += crit.eq.bv - originalAmmo.bv; } } } @@ -904,12 +902,10 @@ export class CBTForceUnit extends ForceUnit { if (item.equipment instanceof AmmoEquipment && item.ammo && item.ammo !== item.name) { const customAmmo = equipmentList[item.ammo] as AmmoEquipment | undefined; if (customAmmo) { - const originalBv = item.equipment.bv; - const currentBv = customAmmo.bv; - if (originalBv === "variable" || currentBv === "variable") { + if (!item.equipment.hasFixedBV() || !customAmmo.hasFixedBV()) { continue; // Skip variable BV. TODO: need to be handle when we have BaseEntity } - bvVariation += currentBv - originalBv; + bvVariation += customAmmo.bv - item.equipment.bv; } } } diff --git a/src/app/models/entity/base-entity.ts b/src/app/models/entity/base-entity.ts index e95c0916a..608b5e96d 100644 --- a/src/app/models/entity/base-entity.ts +++ b/src/app/models/entity/base-entity.ts @@ -452,7 +452,7 @@ export abstract class BaseEntity implements EntityTechnology { for (const mount of this.equipment()) { const equipment = mount.equipment; if (!equipment || equipment.hasFlag('F_CASE') || equipment.hasFlag('F_CASE_II')) continue; - if (!equipment.isExplosive() && mount.secondEquipment?.isExplosive() !== true) continue; + if (!equipment.isExplosive()) continue; for (const location of mount.getOccupiedLocations()) { if (location !== 'Unallocated' && !protectedLocations.has(location) && !optedOut.has(location)) { locations.add(location); @@ -471,7 +471,7 @@ export abstract class BaseEntity implements EntityTechnology { for (const mount of this.equipment()) { const equipment = mount.equipment; if (!equipment || equipment.hasFlag('F_CASE')) continue; - if (!equipment.isExplosive() && mount.secondEquipment?.isExplosive() !== true) continue; + if (!equipment.isExplosive()) continue; for (const location of mount.getOccupiedLocations()) { if (location !== 'Unallocated' && !optedOut.has(location)) locations.add(location); } diff --git a/src/app/models/entity/bays/bay-definitions.spec.ts b/src/app/models/entity/bays/bay-definitions.spec.ts index 61acbf7aa..52651c8f6 100644 --- a/src/app/models/entity/bays/bay-definitions.spec.ts +++ b/src/app/models/entity/bays/bay-definitions.spec.ts @@ -17,6 +17,10 @@ describe('bay definitions', () => { expect(getBayConstructionWeight(fighterBay)).toBe(900); expect(getBayConstructionWeight({ ...fighterBay, configuration: { type: 'fighter', arts: true } })).toBe(1125); + expect(getBayConstructionWeight({ ...fighterBay, + configuration: { type: 'light-vehicle' }, capacity: 1 })).toBe(50); + expect(getBayConstructionWeight({ ...fighterBay, + configuration: { type: 'battle-armor', techBase: 'IS', comStar: false }, capacity: 4.5 })).toBe(36); }); it('uses fixed DropShuttle bay construction mass', () => { diff --git a/src/app/models/entity/entities/mek/mek-entity.ts b/src/app/models/entity/entities/mek/mek-entity.ts index 29d8a03ac..0e792a6e1 100644 --- a/src/app/models/entity/entities/mek/mek-entity.ts +++ b/src/app/models/entity/entities/mek/mek-entity.ts @@ -33,7 +33,7 @@ import { Signal, computed, signal } from '@angular/core'; import { EquipmentRegistry } from '../../../equipment-lookup'; -import { MiscEquipment } from '../../../equipment.model'; +import { AmmoEquipment, MiscEquipment } from '../../../equipment.model'; import { BaseEntity, @@ -596,7 +596,7 @@ export abstract class MekEntity extends BaseEntity { if (chassisTonnage <= 0) return 0; const movement = jumpJets.reduce((total, mount) => { - if (mount.equipment?.tonnage !== 'variable') return total + 1; + if (mount.equipment?.hasFixedTonnage()) return total + 1; const locationTonnage = Math.min( this.structureAt(mount.location).tonnage, chassisTonnage, @@ -792,18 +792,17 @@ export abstract class MekEntity extends BaseEntity { grid.set(loc as string, slots); } - // Overlay equipment placements + // Overlay equipment placements. Superheavy Meks may share a physical slot + // between two canonical mounts (ammunition and eligible heat-sink loads). for (const mount of this.equipment()) { if (!mount.placements) continue; for (const p of mount.placements) { const slots = grid.get(p.location); if (slots && p.slotIndex >= 0 && p.slotIndex < MEK_SLOTS_PER_LOCATION) { - slots[p.slotIndex] = { - type: 'equipment', - mount, - armored: mount.armored, - omniPod: mount.omniPodMounted, - }; + const existing = slots[p.slotIndex]; + slots[p.slotIndex] = existing.type === 'equipment' + ? this.appendEquipmentToCriticalSlot(existing, mount) ?? this.equipmentCriticalSlot(mount) + : this.equipmentCriticalSlot(mount); } } } @@ -869,22 +868,15 @@ export abstract class MekEntity extends BaseEntity { }); } - // Crit slot overflow (derived grid vs slots-per-location) - for (const [loc, slots] of this.criticalSlotGrid()) { - const usedSlots = slots.filter(s => s.type !== 'empty').length; - if (usedSlots > MEK_SLOTS_PER_LOCATION) { - msgs.push({ - severity: 'error', category: 'crit', code: 'CRIT_SLOTS_OVERFLOW', - message: `${loc} has ${usedSlots} crit slots but max is ${MEK_SLOTS_PER_LOCATION}`, - location: loc, - }); - } - } - // Equipment placed on system slots (placement conflict) + const placementsBySlot = new Map(); for (const mount of this.equipment()) { if (!mount.placements) continue; for (const p of mount.placements) { + const slotKey = `${p.location}:${p.slotIndex}`; + const mounts = placementsBySlot.get(slotKey) ?? []; + mounts.push(mount); + placementsBySlot.set(slotKey, mounts); const systemSlots = this.getSystemSlotsForLocation(p.location); if (p.slotIndex < systemSlots.length && systemSlots[p.slotIndex].type === 'system') { msgs.push({ @@ -896,6 +888,24 @@ export abstract class MekEntity extends BaseEntity { } } + for (const [slotKey, mounts] of placementsBySlot) { + if (mounts.length < 2) continue; + let slot = this.equipmentCriticalSlot(mounts[0]); + for (const mount of mounts.slice(1)) { + const sharedSlot = this.appendEquipmentToCriticalSlot(slot, mount); + if (sharedSlot) { + slot = sharedSlot; + continue; + } + msgs.push({ + severity: 'error', category: 'crit', code: 'CRIT_SLOT_SHARING_INVALID', + message: `Critical slot ${slotKey} cannot be shared by "${mounts.map(item => item.equipmentId).join('", "')}"`, + location: mounts[0].location, + }); + break; + } + } + return msgs; }); @@ -958,6 +968,41 @@ export abstract class MekEntity extends BaseEntity { } } + private equipmentCriticalSlot( + mount: EntityMountedEquipment, + ): Extract { + return { + type: 'equipment', mounts: [mount], + armored: mount.armored, + omniPod: mount.omniPodMounted, + }; + } + + private appendEquipmentToCriticalSlot( + existing: Extract, + incoming: EntityMountedEquipment, + ): Extract | null { + if (!this.isSuperHeavy() || existing.mounts.length >= 2 + || existing.armored !== incoming.armored + || existing.omniPod !== incoming.omniPodMounted + || existing.mounts.some(mount => mount.mountId === incoming.mountId)) return null; + + const incomingEquipment = incoming.equipment; + if (!incomingEquipment) return null; + if (incomingEquipment instanceof AmmoEquipment) { + if (!existing.mounts.every(mount => mount.equipment instanceof AmmoEquipment)) return null; + } else { + if (!incomingEquipment.hasFlag('F_HEAT_SINK') + || !existing.mounts.every(mount => mount.equipment?.hasFlag('F_HEAT_SINK'))) return null; + } + const mounts = [...existing.mounts, incoming] as [EntityMountedEquipment, ...EntityMountedEquipment[]]; + return { + type: 'equipment', mounts, + armored: mounts.some(mount => mount.armored), + omniPod: mounts.some(mount => mount.omniPodMounted), + }; + } + private getArmSystemSlots(loc: string): CriticalSlotView[] { const slots: CriticalSlotView[] = [sys('Shoulder'), sys('Upper Arm Actuator')]; if (this instanceof MekWithArmsEntity) { diff --git a/src/app/models/entity/parsers/blk-codec.spec.ts b/src/app/models/entity/parsers/blk-codec.spec.ts index be458bd54..e91994d13 100644 --- a/src/app/models/entity/parsers/blk-codec.spec.ts +++ b/src/app/models/entity/parsers/blk-codec.spec.ts @@ -147,7 +147,8 @@ describe('BLK codec', () => { it('decodes compound tech codes only into domain tech bases', () => { for (const code of [2, 6, 8, 10, 12]) expect(decodeBlkCompoundTechBase(code, 'IS')).toBe('Clan'); for (const code of [0, 1, 3, 5, 7, 9, 11]) expect(decodeBlkCompoundTechBase(code, 'Clan')).toBe('IS'); - for (const code of [-1, 4, 13]) expect(decodeBlkCompoundTechBase(code, 'Clan')).toBe('Clan'); + expect(decodeBlkCompoundTechBase(-1, 'Clan')).toBe('IS'); + for (const code of [4, 13]) expect(decodeBlkCompoundTechBase(code, 'Clan')).toBe('Clan'); }); it('parses and canonically encodes BLK entity tech levels', () => { diff --git a/src/app/models/entity/parsers/blk-codec.ts b/src/app/models/entity/parsers/blk-codec.ts index e05c3462f..b5531b066 100644 --- a/src/app/models/entity/parsers/blk-codec.ts +++ b/src/app/models/entity/parsers/blk-codec.ts @@ -66,6 +66,9 @@ export function decodeBlkCompoundTechBase(code: number, fallback: EntityTechBase const scope = decodeBlkCompoundTechLevel(code).scope; if (scope === 'Clan' || scope === 'All Clan') return 'Clan'; if (scope === 'IS' || scope === 'IS TW' || scope === 'All IS') return 'IS'; + // MegaMek's TechConstants.isClan(T_TECH_UNKNOWN) is false. An explicit -1 + // therefore resolves an ambiguous armor descriptor through the IS branch. + if (code === -1) return 'IS'; return fallback; } diff --git a/src/app/models/entity/parsers/blk-constants.ts b/src/app/models/entity/parsers/blk-constants.ts index b47523692..bf4d9e99d 100644 --- a/src/app/models/entity/parsers/blk-constants.ts +++ b/src/app/models/entity/parsers/blk-constants.ts @@ -197,6 +197,15 @@ export const BLK_CRIT_QUAD: readonly (readonly [string, string])[] = [ // ============================================================================ export const VEHICLE_ARMOR_LOCS = ['Front', 'Right', 'Left', 'Rear', 'Turret', 'Rear Turret'] as const; +export const VEHICLE_DUAL_TURRET_ARMOR_LOCS = [ + 'Front', 'Right', 'Left', 'Rear', 'Rear Turret', 'Front Turret', +] as const; + +export function ordinaryVehicleArmorLocations(entryCount: number): readonly string[] { + return entryCount >= VEHICLE_DUAL_TURRET_ARMOR_LOCS.length + ? VEHICLE_DUAL_TURRET_ARMOR_LOCS + : VEHICLE_ARMOR_LOCS; +} export const VTOL_ARMOR_LOCS = ['Front', 'Right', 'Left', 'Rear', 'Rotor', 'Turret'] as const; export const SUPERHEAVY_ARMOR_LOCS = ['Front', 'Front Right', 'Front Left', 'Rear Right', 'Rear Left', 'Rear', 'Turret', 'Rear Turret'] as const; export const LST_ARMOR_LOCS = ['Front', 'Front Right', 'Front Left', 'Rear Right', 'Rear Left', 'Rear', 'Turret'] as const; diff --git a/src/app/models/entity/parsers/blk-mek-parser.ts b/src/app/models/entity/parsers/blk-mek-parser.ts index 3e1e0b7fc..4115bc3cb 100644 --- a/src/app/models/entity/parsers/blk-mek-parser.ts +++ b/src/app/models/entity/parsers/blk-mek-parser.ts @@ -147,46 +147,45 @@ export function parseBlkMek(bb: BuildingBlock, ctx: ParseContext): MekEntity { // Skip system slots (they're derived from config) if (isSystemSlotName(raw)) continue; - const parsed = parseEquipmentLine(raw); - const resolved = ctx.resolveEquipment(parsed.name, critTag); - - // Spreadable equipment merges all crits into one mount while incomplete - if (resolved?.isSpreadable) { - const existingIdx = spreadableMap.get(parsed.name); - if (existingIdx !== undefined) { - const existing = equipmentList[existingIdx]; - const expectedCrits = existing.equipment?.getNumCriticalSlots(entity, existing.size ?? 0) ?? Infinity; - if ((existing.placements?.length ?? 0) < expectedCrits) { - equipmentList[existingIdx] = existing.clone({ - allocation: { - kind: 'location', - location: existing.location, - placements: [...(existing.placements ?? []), { location: locCode, slotIndex: slotIdx }], - }, - }); - continue; + const parsedMembers = raw.split('|').map(member => parseEquipmentLine(member)); + const omniPod = parsedMembers.some(member => member.omniPod); + + for (const parsedMember of parsedMembers) { + const parsed = { ...parsedMember, omniPod }; + const resolved = ctx.resolveEquipment(parsed.name, critTag); + + // Spreadable equipment merges all crits into one mount while incomplete + if (resolved?.isSpreadable) { + const existingIdx = spreadableMap.get(parsed.name); + if (existingIdx !== undefined) { + const existing = equipmentList[existingIdx]; + const expectedCrits = existing.getNumCriticalSlots(entity) ?? Infinity; + if (existing.placedCriticalSlotCount < expectedCrits) { + equipmentList[existingIdx] = existing.withAddedPlacement({ location: locCode, slotIndex: slotIdx }); + continue; + } } } - } - const idx = equipmentList.length; - equipmentList.push(entity.addEquipment({ - equipmentId: parsed.name, - equipment: resolved ?? undefined, - allocation: { - kind: 'location', - location: locCode, - placements: [{ location: locCode, slotIndex: slotIdx }], - }, - rearMounted: parsed.rearMounted, - turretMounted: false, - omniPodMounted: parsed.omniPod, - armored: false, - size: parsed.size, - facing: parsed.facing, - })); - - if (resolved?.isSpreadable) spreadableMap.set(parsed.name, idx); + const idx = equipmentList.length; + equipmentList.push(entity.addEquipment({ + equipmentId: parsed.name, + equipment: resolved ?? undefined, + allocation: { + kind: 'location', + location: locCode, + placements: [{ location: locCode, slotIndex: slotIdx }], + }, + rearMounted: parsed.rearMounted, + turretMounted: false, + omniPodMounted: parsed.omniPod, + armored: false, + size: parsed.size, + facing: parsed.facing, + })); + + if (resolved?.isSpreadable) spreadableMap.set(parsed.name, idx); + } } } diff --git a/src/app/models/entity/parsers/blk-vehicle-armor-codec.spec.ts b/src/app/models/entity/parsers/blk-vehicle-armor-codec.spec.ts new file mode 100644 index 000000000..e1964aa32 --- /dev/null +++ b/src/app/models/entity/parsers/blk-vehicle-armor-codec.spec.ts @@ -0,0 +1,18 @@ +import { ordinaryVehicleArmorLocations } from './blk-constants'; + +describe('ordinary vehicle BLK armor locations', () => { + it('keeps turretless armor in hull order', () => { + expect(ordinaryVehicleArmorLocations(4).slice(0, 4)) + .toEqual(['Front', 'Right', 'Left', 'Rear']); + }); + + it('maps a single turret to the legacy Turret location', () => { + expect(ordinaryVehicleArmorLocations(5).slice(0, 5)) + .toEqual(['Front', 'Right', 'Left', 'Rear', 'Turret']); + }); + + it('maps dual-turret armor in MegaMek rear-then-front order', () => { + expect(ordinaryVehicleArmorLocations(6).slice(0, 6)) + .toEqual(['Front', 'Right', 'Left', 'Rear', 'Rear Turret', 'Front Turret']); + }); +}); \ No newline at end of file diff --git a/src/app/models/entity/parsers/blk-vehicle-parser.ts b/src/app/models/entity/parsers/blk-vehicle-parser.ts index 4ada39db2..82d5cc503 100644 --- a/src/app/models/entity/parsers/blk-vehicle-parser.ts +++ b/src/app/models/entity/parsers/blk-vehicle-parser.ts @@ -51,8 +51,8 @@ import { BuildingBlock } from './building-block'; import { LST_EXTRA_EQUIP_TAGS, LST_ARMOR_LOCS, + ordinaryVehicleArmorLocations, SUPERHEAVY_ARMOR_LOCS, - VEHICLE_ARMOR_LOCS, VEHICLE_EQUIP_TAGS, VTOL_ARMOR_LOCS, } from './blk-constants'; @@ -207,9 +207,10 @@ export function parseBlkVehicle(bb: BuildingBlock, ctx: ParseContext): VehicleEn entity.hasDualTurret.set(true); } } else { - // Tank: Front, Right, Left, Rear[, Turret[, Rear Turret]] - for (let i = 0; i < VEHICLE_ARMOR_LOCS.length && i < ints.length; i++) { - armorMap.set(VEHICLE_ARMOR_LOCS[i], locationArmor(ints[i])); + // Tank: Front, Right, Left, Rear[, Turret] or Rear Turret, Front Turret + const locations = ordinaryVehicleArmorLocations(ints.length); + for (let i = 0; i < locations.length && i < ints.length; i++) { + armorMap.set(locations[i], locationArmor(ints[i])); } // Infer turret presence from armor array length if (ints.length >= 5 && !entity.hasTurret()) { diff --git a/src/app/models/entity/parsers/mtf-parser.spec.ts b/src/app/models/entity/parsers/mtf-parser.spec.ts index 2671fecfb..1f1eb8374 100644 --- a/src/app/models/entity/parsers/mtf-parser.spec.ts +++ b/src/app/models/entity/parsers/mtf-parser.spec.ts @@ -307,6 +307,95 @@ describe('MTF parser identity', () => { expect([...entity.implicitClanCaseLocations()].sort()).toEqual(['RA', 'RT']); }); + it('materializes both sides of a superheavy combined ammo slot', () => { + const ammo = new AmmoEquipment({ + id: 'Test Ammo', name: 'Test Ammo', type: 'ammo', stats: { criticalSlots: 1 }, + }); + const entity = parseMtf( + minimalMtf() + .replace('mass:20', 'mass:150') + + 'Right Arm:\nShoulder\nUpper Arm Actuator\nTest Ammo|Test Ammo (OMNIPOD)\n', + new ParseContext('superheavy-ammo.mtf', equipmentRegistry({ [ammo.id]: ammo })), + ); + + const mounts = entity.equipment().filter(mount => mount.equipmentId === ammo.id); + expect(mounts).toHaveSize(2); + expect(mounts.map(mount => mount.placements)).toEqual([ + [{ location: 'RA', slotIndex: 2 }], + [{ location: 'RA', slotIndex: 2 }], + ]); + expect(mounts.map(mount => mount.omniPodMounted)).toEqual([true, true]); + expect(entity.criticalSlotGrid().get('RA')?.[2]).toEqual(jasmine.objectContaining({ + type: 'equipment', mounts, omniPod: true, + })); + expect(writeMtf(entity)).toContain('\nTest Ammo|Test Ammo (OMNIPOD)\n'); + expect(entity.validationResult().messages).not.toContain(jasmine.objectContaining({ + code: 'CRIT_SLOT_SHARING_INVALID', + })); + }); + + it('materializes consecutive single-slot variable cargo as distinct mounts', () => { + const cargo = new MiscEquipment({ + id: 'Cargo', name: 'Cargo', type: 'misc', + stats: { criticalSlots: 'variable', tonnage: 'variable' }, flags: ['F_CARGO'], + }); + const entity = parseMtf( + minimalMtf() + 'Center Torso:\nCargo:SIZE:1.0\nCargo:SIZE:1.0\n', + new ParseContext('separate-cargo.mtf', equipmentRegistry({ [cargo.id]: cargo })), + ); + + const mounts = entity.equipment().filter(mount => mount.equipment === cargo); + expect(mounts).toHaveSize(2); + expect(mounts.map(mount => mount.size)).toEqual([1, 1]); + expect(mounts.map(mount => mount.placedCriticalSlotCount)).toEqual([1, 1]); + }); + + it('preserves distinct sizes for consecutive variable cargo mounts', () => { + const cargo = new MiscEquipment({ + id: 'Cargo', name: 'Cargo', type: 'misc', + stats: { criticalSlots: 'variable', tonnage: 'variable' }, flags: ['F_CARGO'], + }); + const entity = parseMtf( + minimalMtf() + 'Center Torso:\nCargo:SIZE:1.0\nCargo:SIZE:0.5\n', + new ParseContext('mixed-cargo.mtf', equipmentRegistry({ [cargo.id]: cargo })), + ); + + const mounts = entity.equipment().filter(mount => mount.equipment === cargo); + expect(mounts).toHaveSize(2); + expect(mounts.map(mount => mount.size)).toEqual([1, 0.5]); + }); + + it('merges consecutive critical rows until a variable mount reaches its requirement', () => { + const communications = new MiscEquipment({ + id: 'Communications Equipment', name: 'Communications Equipment', type: 'misc', + stats: { criticalSlots: 'variable', tonnage: 'variable' }, flags: ['F_COMMUNICATIONS'], + }); + const entity = parseMtf( + minimalMtf() + 'Center Torso:\n' + + Array.from({ length: 6 }, () => 'Communications Equipment:SIZE:3.0').join('\n') + '\n', + new ParseContext('communications.mtf', equipmentRegistry({ [communications.id]: communications })), + ); + + const mounts = entity.equipment().filter(mount => mount.equipment === communications); + expect(mounts).toHaveSize(2); + expect(mounts.map(mount => mount.placedCriticalSlotCount)).toEqual([3, 3]); + expect(mounts.map(mount => mount.size)).toEqual([3, 3]); + }); + + it('rejects combined equipment in a non-superheavy critical slot', () => { + const ammo = new AmmoEquipment({ + id: 'Test Ammo', name: 'Test Ammo', type: 'ammo', stats: { criticalSlots: 1 }, + }); + const entity = parseMtf( + minimalMtf() + 'Right Arm:\nShoulder\nUpper Arm Actuator\nTest Ammo|Test Ammo\n', + new ParseContext('normal-combined-ammo.mtf', equipmentRegistry({ [ammo.id]: ammo })), + ); + + expect(entity.validationResult().messages).toContain(jasmine.objectContaining({ + code: 'CRIT_SLOT_SHARING_INVALID', location: 'RA', + })); + }); + it('does not propagate implicit Clan CASE on an Inner Sphere unit with explicit Clan CASE', () => { const clanCase = new MiscEquipment({ id: 'Clan CASE', name: 'CASE', type: 'misc', tech: { base: 'Clan' }, flags: ['F_CASE'], diff --git a/src/app/models/entity/parsers/mtf-parser.ts b/src/app/models/entity/parsers/mtf-parser.ts index db5c10a48..062b67986 100644 --- a/src/app/models/entity/parsers/mtf-parser.ts +++ b/src/app/models/entity/parsers/mtf-parser.ts @@ -417,18 +417,19 @@ export function parseMtf(content: string, ctx: ParseContext): MekEntity { const raw = slotLines[slotIdx]; if (raw === '-Empty-') continue; - const parsed = parseCritSlotLine(raw); - - // System slots are skipped - they're derived from configuration, - // but we still capture the ARMORED flag for round-trip fidelity. - if (SYSTEM_NAMES[parsed.name] || isEngineSlot(parsed.name)) { - if (parsed.armored) armoredSystemSlots.add(`${locCode}:${slotIdx}`); - continue; - } + const parsedSlots = parseCritSlotLine(raw); + for (const [memberIndex, parsed] of parsedSlots.entries()) { + // System slots are skipped - they're derived from configuration, + // but we still capture the ARMORED flag for round-trip fidelity. + if (SYSTEM_NAMES[parsed.name] || isEngineSlot(parsed.name)) { + if (parsed.armored) armoredSystemSlots.add(`${locCode}:${slotIdx}`); + continue; + } - // Equipment slot - find existing multi-crit mount or create new one - const dedupKey = `${parsed.name}@${locCode}`; - const existingId = parsed.isSplit ? undefined : multiCritMap.get(dedupKey); + // The pair position distinguishes two same-named mounts sharing a + // superheavy slot while still merging their later critical entries. + const dedupKey = `${parsed.name}@${locCode}@${memberIndex}`; + const existingId = parsed.isSplit ? undefined : multiCritMap.get(dedupKey); let addedToExisting = false; if (existingId) { @@ -436,17 +437,11 @@ export function parseMtf(content: string, ctx: ParseContext): MekEntity { if (mountIndex >= 0) { const mount = mountedEquipment[mountIndex]; // Resolve expected crit count via entity context - const criticalSlots = mount.equipment?.getNumCriticalSlots(entity, mount.size ?? 0) ?? Infinity; + const criticalSlots = numericCriticalSlotRequirement(mount, entity); const lastPlacement = mount.placements?.[mount.placements.length - 1]; const isConsecutive = lastPlacement?.location === locCode && lastPlacement.slotIndex === slotIdx - 1; - if ((mount.placements?.length ?? 0) < criticalSlots && isConsecutive) { - mountedEquipment[mountIndex] = mount.clone({ - allocation: { - kind: 'location', - location: mount.location, - placements: [...(mount.placements ?? []), { location: locCode, slotIndex: slotIdx }], - }, - }); + if (mount.placedCriticalSlotCount < criticalSlots && isConsecutive) { + mountedEquipment[mountIndex] = mount.withAddedPlacement({ location: locCode, slotIndex: slotIdx }); addedToExisting = true; } } @@ -462,15 +457,8 @@ export function parseMtf(content: string, ctx: ParseContext): MekEntity { ); if (targetingComputerIndex >= 0) { const targetingComputer = mountedEquipment[targetingComputerIndex]; - mountedEquipment[targetingComputerIndex] = targetingComputer.clone({ - allocation: { - kind: 'location', - location: targetingComputer.location, - placements: [ - ...(targetingComputer.placements ?? []), - { location: locCode, slotIndex: slotIdx }, - ], - }, + mountedEquipment[targetingComputerIndex] = targetingComputer.withAddedPlacement({ + location: locCode, slotIndex: slotIdx, }); addedToExisting = true; } @@ -485,17 +473,13 @@ export function parseMtf(content: string, ctx: ParseContext): MekEntity { if (m.equipmentId !== parsed.name) return false; const eq = m.equipment; if (!eq?.isSpreadable) return false; - const expectedCrits = eq.getNumCriticalSlots(entity, m.size ?? 0) ?? Infinity; - return (m.placements?.length ?? 0) < expectedCrits; + const expectedCrits = numericCriticalSlotRequirement(m, entity); + return m.placedCriticalSlotCount < expectedCrits; }); if (existingSpreadableIndex >= 0) { const existingSpreadable = mountedEquipment[existingSpreadableIndex]; - mountedEquipment[existingSpreadableIndex] = existingSpreadable.clone({ - allocation: { - kind: 'location', - location: existingSpreadable.location, - placements: [...(existingSpreadable.placements ?? []), { location: locCode, slotIndex: slotIdx }], - }, + mountedEquipment[existingSpreadableIndex] = existingSpreadable.withAddedPlacement({ + location: locCode, slotIndex: slotIdx, }); addedToExisting = true; } @@ -507,9 +491,8 @@ export function parseMtf(content: string, ctx: ParseContext): MekEntity { const incompleteIndex = mountedEquipment.findIndex(m => { if (m.equipmentId !== parsed.name) return false; if (!m.equipment?.canSplit()) return false; - const criticalSlots = m.equipment.getNumCriticalSlots(entity); - if (criticalSlots == null) return false; - return (m.placements?.length ?? 0) < criticalSlots + const criticalSlots = numericCriticalSlotRequirement(m, entity); + return m.placedCriticalSlotCount < criticalSlots && m.location !== locCode && areLocationsAdjacent(m.location, locCode); }); @@ -517,20 +500,15 @@ export function parseMtf(content: string, ctx: ParseContext): MekEntity { const incomplete = mountedEquipment[incompleteIndex]; // Primary location is the more restrictive one (torso > arm) const primaryLocation = getSplitPrimaryLocation(incomplete.location, locCode); - const updated = incomplete.clone({ - allocation: { - kind: 'location', - location: primaryLocation, - placements: [...(incomplete.placements ?? []), { location: locCode, slotIndex: slotIdx }], - }, - isSplit: true, - }); + const updated = incomplete.withAddedPlacement( + { location: locCode, slotIndex: slotIdx }, primaryLocation, + ); mountedEquipment[incompleteIndex] = updated; // Update multiCritMap so further crits in the new primary location // can find this mount (e.g. AC/20 split RT+CT: after merging the // first CT crit the location becomes CT, subsequent CT crits must // still de-duplicate to the same mount). - multiCritMap.set(`${updated.equipmentId}@${updated.location}`, updated.mountId); + multiCritMap.set(`${updated.equipmentId}@${updated.location}@${memberIndex}`, updated.mountId); addedToExisting = true; } } @@ -551,18 +529,14 @@ export function parseMtf(content: string, ctx: ParseContext): MekEntity { turretMounted: parsed.turretMounted, omniPodMounted: parsed.omniPod, armored: parsed.armored, - isSplit: parsed.isSplit || undefined, facing: parsed.facing, size: parsed.variableSize, - secondEquipmentId: parsed.secondEquipmentName, - secondEquipment: parsed.secondEquipmentName - ? ctx.resolveEquipment(parsed.secondEquipmentName, locCode, entity.techBase()) ?? undefined - : undefined, }); mountedEquipment.push(mount); multiCritMap.set(dedupKey, mount.mountId); } + } } } @@ -921,16 +895,23 @@ interface ParsedCritLine { isSplit: boolean; facing?: number; variableSize?: number; - secondEquipmentName?: string; } -function parseCritSlotLine(raw: string): ParsedCritLine { +function parseCritSlotLine(raw: string): ParsedCritLine[] { + const slots = raw.split('|').map(parseMountedCritSlot); + const armored = slots.some(slot => slot.armored); + const omniPod = slots.some(slot => slot.omniPod); + // Superheavy sharing applies to the physical slot. Both canonical mounts + // carry that state so cost, BV, and serialization cannot observe a half-slot. + return slots.map(slot => ({ ...slot, armored, omniPod })); +} + +function parseMountedCritSlot(raw: string): ParsedCritLine { let name = raw; let omniPod = false, armored = false, rearMounted = false; let turretMounted = false, isSplit = false; let facing: number | undefined; let variableSize: number | undefined; - let secondEquipmentName: string | undefined; // Parenthesised suffixes const suffixRe = /\s*\((omnipod|armored|r|t|split|fl|fr|rl|rr)\)/gi; @@ -957,14 +938,7 @@ function parseCritSlotLine(raw: string): ParsedCritLine { name = name.substring(0, name.indexOf(':SIZE:')); } - // Combined slot name1|name2 - if (name.includes('|')) { - const parts = name.split('|'); - name = parts[0]; - secondEquipmentName = parts[1]; - } - - return { name, omniPod, armored, rearMounted, turretMounted, isSplit, facing, variableSize, secondEquipmentName }; + return { name, omniPod, armored, rearMounted, turretMounted, isSplit, facing, variableSize }; } function isEngineSlot(name: string): boolean { @@ -1025,3 +999,7 @@ function getSplitPrimaryLocation(locA: string, locB: string): string { // Fallback: keep first return locA; } + +function numericCriticalSlotRequirement(mount: EntityMountedEquipment, entity: MekEntity): number { + return mount.getNumCriticalSlots(entity) ?? Infinity; +} diff --git a/src/app/models/entity/types/equipment.spec.ts b/src/app/models/entity/types/equipment.spec.ts index 2aec4ee73..8986bb4f9 100644 --- a/src/app/models/entity/types/equipment.spec.ts +++ b/src/app/models/entity/types/equipment.spec.ts @@ -1,4 +1,4 @@ -import { AmmoEquipment, WeaponEquipment } from '../../equipment.model'; +import { AmmoEquipment, MiscEquipment, WeaponEquipment } from '../../equipment.model'; import { TestBipedMekEntity as BipedMekEntity } from '../testing/test-entities'; import { EntityMountedEquipment } from './equipment'; @@ -24,18 +24,9 @@ describe('EntityMountedEquipment characteristics', () => { }); expect(mount.getOccupiedLocations()).toEqual(['RT', 'RA']); - expect(mount.getCriticalSlotRequirement(entity)).toBe(8); - expect(mount.getWeaponCharacteristics(entity)).toEqual({ - name: 'Split Weapon', - heat: 0, - category: 'ballistic', - ranges: [5, 10, 15, 20], - minimumRange: 3, - damage: { kind: 'fixed', damage: 10, maximum: 10, perShot: false }, - hitModifiers: [0], - criticalSlots: 8, - oneShotCount: undefined, - }); + expect(mount.placedCriticalSlotCount).toBe(3); + expect(mount.isSplitAcrossLocations).toBeTrue(); + expect(mount.getNumCriticalSlots(entity)).toBe(8); }); it('uses mounted ammo shots when present and definition shots otherwise', () => { @@ -47,6 +38,17 @@ describe('EntityMountedEquipment characteristics', () => { expect(mounted(ammo, { shotsCount: 7 }).getAmmoShots()).toBe(7); }); + it('resolves variable critical slots from the mounted size', () => { + const cargo = new MiscEquipment({ + id: 'cargo', name: 'Cargo', type: 'misc', + stats: { criticalSlots: 'variable', tonnage: 'variable' }, flags: ['F_CARGO'], + }); + const entity = new BipedMekEntity(); + + expect(mounted(cargo, { size: 0.5 }).getNumCriticalSlots(entity)).toBe(1); + expect(mounted(cargo, { size: 3 }).getNumCriticalSlots(entity)).toBe(3); + }); + it('derives engine and unallocated locations from canonical allocation', () => { const equipment = new AmmoEquipment({ id: 'ammo', name: 'Ammo', type: 'ammo', ammo: { type: 'AC', shots: 20 }, @@ -56,29 +58,46 @@ describe('EntityMountedEquipment characteristics', () => { expect(mounted(equipment, { allocation: { kind: 'unallocated' } }).location).toBe('Unallocated'); }); - it('replaces allocation without mutating the original mount', () => { + it('adds a placement without mutating the original mount', () => { const equipment = new AmmoEquipment({ id: 'ammo', name: 'Ammo', type: 'ammo', ammo: { type: 'AC', shots: 20 }, }); - const integrated = mounted(equipment, { allocation: { kind: 'engine' } }); - - const allocated = integrated.withAllocation({ + const original = mounted(equipment, { allocation: { kind: 'location', location: 'RT', - placements: [{ location: 'RT', slotIndex: 4 }], - }); + placements: [{ location: 'RT', slotIndex: 3 }], + } }); - expect(integrated.allocation).toEqual({ kind: 'engine' }); - expect(allocated.allocation).toEqual({ - kind: 'location', - location: 'RT', - placements: [{ location: 'RT', slotIndex: 4 }], + const updated = original.withAddedPlacement({ location: 'RT', slotIndex: 4 }); + + expect(original.placements).toEqual([{ location: 'RT', slotIndex: 3 }]); + expect(updated.placements).toEqual([ + { location: 'RT', slotIndex: 3 }, + { location: 'RT', slotIndex: 4 }, + ]); + }); + + it('updates a split mount primary location and rejects non-location allocations', () => { + const equipment = new AmmoEquipment({ + id: 'ammo', name: 'Ammo', type: 'ammo', ammo: { type: 'AC', shots: 20 }, }); + const split = mounted(equipment, { allocation: { + kind: 'location', location: 'RA', placements: [{ location: 'RA', slotIndex: 4 }], + } }); + + const relocated = split.withAddedPlacement({ location: 'RT', slotIndex: 0 }, 'RT'); + expect(relocated.location).toBe('RT'); + expect(relocated.placements).toEqual([ + { location: 'RA', slotIndex: 4 }, { location: 'RT', slotIndex: 0 }, + ]); + expect(() => mounted(equipment, { allocation: { kind: 'engine' } }) + .withAddedPlacement({ location: 'CT', slotIndex: 0 })) + .toThrowError('Cannot add a critical placement to engine-allocated equipment'); }); }); function mounted( - equipment: WeaponEquipment | AmmoEquipment, + equipment: WeaponEquipment | AmmoEquipment | MiscEquipment, overrides: Partial[0]> = {}, ): EntityMountedEquipment { return new EntityMountedEquipment({ diff --git a/src/app/models/entity/types/equipment.ts b/src/app/models/entity/types/equipment.ts index 64cf52d93..b241dd795 100644 --- a/src/app/models/entity/types/equipment.ts +++ b/src/app/models/entity/types/equipment.ts @@ -34,7 +34,6 @@ import { AmmoEquipment, Equipment, - type WeaponCharacteristics, WeaponEquipment, } from '../../equipment.model'; import type { BaseEntity } from '../base-entity'; @@ -65,10 +64,6 @@ export type EquipmentAllocation = readonly placements?: readonly MountPlacement[]; }; -export interface MountedWeaponCharacteristics extends WeaponCharacteristics { - readonly criticalSlots: number | 'variable' | undefined; -} - // ============================================================================ // Mounted Equipment - the single canonical equipment model // @@ -122,9 +117,6 @@ export interface EntityMountedEquipmentInit { /** Variable-size equipment size */ size?: number; - /** Split weapon tracking (Mek: crits span multiple locations) */ - isSplit?: boolean; - /** BA mount location */ baMountLocation?: 'Body' | 'LA' | 'RA' | 'Turret'; @@ -140,9 +132,6 @@ export interface EntityMountedEquipmentInit { /** Ammo: shot count */ shotsCount?: number; - /** Combined slot - second equipment in same slot (superheavy Mek) */ - secondEquipmentId?: string; - secondEquipment?: Equipment; } /** Input accepted when installing equipment; entity ownership supplies identity. */ @@ -160,15 +149,11 @@ export class EntityMountedEquipment implements EntityMountedEquipmentInit { armored: boolean; facing?: number; size?: number; - isSplit?: boolean; baMountLocation?: 'Body' | 'LA' | 'RA' | 'Turret'; isDWP?: boolean; isSSWM?: boolean; isAPM?: boolean; shotsCount?: number; - secondEquipmentId?: string; - secondEquipment?: Equipment; - constructor(data: EntityMountedEquipmentInit) { Object.assign(this, data); this.mountId = createMountId(data.mountId); @@ -192,12 +177,20 @@ export class EntityMountedEquipment implements EntityMountedEquipmentInit { return this.allocation.kind === 'location' ? this.allocation.placements : undefined; } - withAllocation(allocation: EquipmentAllocation): EntityMountedEquipment { - return this.clone({ allocation }); + get placedCriticalSlotCount(): number { + return this.placements?.length ?? 0; } - static from(mount: EntityMountedEquipment | EntityMountedEquipmentInit): EntityMountedEquipment { - return mount instanceof EntityMountedEquipment ? mount : new EntityMountedEquipment(mount); + withAddedPlacement(placement: MountPlacement, primaryLocation = this.location): EntityMountedEquipment { + if (this.allocation.kind !== 'location') { + throw new Error(`Cannot add a critical placement to ${this.allocation.kind}-allocated equipment`); + } + return this.clone({ + allocation: { + kind: 'location', location: primaryLocation, + placements: [...(this.placements ?? []), placement], + }, + }); } clone(overrides: Partial = {}): EntityMountedEquipment { @@ -208,9 +201,13 @@ export class EntityMountedEquipment implements EntityMountedEquipmentInit { return [...new Set(this.placements?.map(placement => placement.location) ?? [this.location])]; } - getCriticalSlotRequirement(entity: BaseEntity): number | 'variable' | undefined { + get isSplitAcrossLocations(): boolean { + return this.getOccupiedLocations().length > 1; + } + + /** Resolves this mount's slot count using its entity context and size. */ + getNumCriticalSlots(entity: BaseEntity): number | undefined { if (!this.equipment) return undefined; - if (this.equipment.critSlots === 'variable') return 'variable'; return this.equipment.getNumCriticalSlots(entity, this.size ?? 1); } @@ -219,14 +216,6 @@ export class EntityMountedEquipment implements EntityMountedEquipmentInit { return this.shotsCount ?? this.equipment.shots; } - getWeaponCharacteristics(entity: BaseEntity): MountedWeaponCharacteristics | undefined { - if (!(this.equipment instanceof WeaponEquipment)) return undefined; - return { - ...this.equipment.characteristics, - criticalSlots: this.getCriticalSlotRequirement(entity), - }; - } - getBV(entity: BaseEntity): number { return getEquipmentBV(entity, this); } diff --git a/src/app/models/entity/types/mek.ts b/src/app/models/entity/types/mek.ts index ee0957ece..07e9bf1a6 100644 --- a/src/app/models/entity/types/mek.ts +++ b/src/app/models/entity/types/mek.ts @@ -230,7 +230,9 @@ export type CriticalSlotView = } | { readonly type: 'equipment'; - readonly mount: EntityMountedEquipment; + /** One mount normally; up to two canonical mounts share a superheavy slot. */ + readonly mounts: readonly [EntityMountedEquipment, ...EntityMountedEquipment[]]; + /** Slot-wide state; shared equipment cannot be partially armored or OmniPod-mounted. */ readonly armored: boolean; readonly omniPod: boolean; } @@ -240,6 +242,15 @@ export type CriticalSlotView = readonly omniPod: false; }; +/** Serializes one or two canonical mounts occupying a physical critical slot. */ +export function formatCriticalSlotEquipment( + slot: Extract, + formatMount: (mount: EntityMountedEquipment, isLast: boolean) => string, +): string { + const lastIndex = slot.mounts.length - 1; + return slot.mounts.map((mount, index) => formatMount(mount, index === lastIndex)).join('|'); +} + // ============================================================================ // Internal Structure Lookup Tables // ============================================================================ diff --git a/src/app/models/entity/utils/battle-value/battle-value.spec.ts b/src/app/models/entity/utils/battle-value/battle-value.spec.ts index 7d64babac..527bb2f71 100644 --- a/src/app/models/entity/utils/battle-value/battle-value.spec.ts +++ b/src/app/models/entity/utils/battle-value/battle-value.spec.ts @@ -165,6 +165,105 @@ describe('battle value family dispatch', () => { expect(new ExposedMekCalculator(new ImmobileLamHarness()).runningModifier()).toBe(0); }); + it('applies Mek summary modifier precedence without stacking cockpit and drone reductions', () => { + class ExposedMekCalculator extends MekBVCalculator { + summary(value: number): number { return this.summarize(value); } + } + const drone = new MiscEquipment({ + id: 'Drone OS', name: 'Drone OS', type: 'misc', flags: ['F_DRONE_OPERATING_SYSTEM'], + }); + const standard = new TestBipedMekEntity(); + standard.setEquipment([mount(drone, 'CT')]); + expect(new ExposedMekCalculator(standard).summary(100)).toBe(95); + + const small = new TestBipedMekEntity(); + small.cockpitType.set('Small'); + small.setEquipment([mount(drone, 'CT')]); + expect(new ExposedMekCalculator(small).summary(100)).toBe(95); + + const virtualReality = new TestBipedMekEntity(); + virtualReality.cockpitType.set('Virtual Reality Piloting Pod'); + expect(new ExposedMekCalculator(virtualReality).summary(100)).toBe(140); + virtualReality.hasRiscHeatSinkOverrideKit.set(true); + expect(new ExposedMekCalculator(virtualReality).summary(100)).toBeCloseTo(141.4, 10); + }); + + it('counts both equipment items in a superheavy combined critical slot', () => { + const primary = new AmmoEquipment({ + id: 'primary-ammo', name: 'Primary Ammo', type: 'ammo', stats: { bv: 10 }, + ammo: { type: 'AC', rackSize: 10, shots: 10 }, + }); + const secondary = new AmmoEquipment({ + id: 'secondary-ammo', name: 'Secondary Ammo', type: 'ammo', stats: { bv: 20 }, + ammo: { type: 'GAUSS', rackSize: 15, shots: 8 }, + }); + const entity = new TestBipedMekEntity(); + entity.setTonnage(150); + entity.setEquipment([mount(primary, 'RT'), mount(secondary, 'RT')]); + + const items = entity.equipment(); + expect(items.map(item => item.equipment?.id)).toEqual([primary.id, secondary.id]); + expect(items[1].location).toBe('RT'); + }); + + it('treats a PPC as explosive only when linked to a capacitor', () => { + class ExposedMekCalculator extends MekBVCalculator { + explosive(mounted: EntityMountedEquipment): boolean { return this.isExplosive(mounted); } + } + const ppc = new WeaponEquipment({ + id: 'ppc', name: 'PPC', type: 'weapon', flags: ['F_PPC', 'F_PPC_CAPACITOR_COMPATIBLE'], + stats: { explosive: false }, weapon: { heat: 10, damage: 10, ammoType: 'NA' }, + }); + const capacitor = new MiscEquipment({ + id: 'capacitor', name: 'PPC Capacitor', type: 'misc', flags: ['F_PPC_CAPACITOR'], + }); + const ppcMount = mount(ppc, 'RA'); + const capacitorMount = mount(capacitor, 'RA'); + const entity = new TestBipedMekEntity(); + entity.setEquipment([ppcMount, capacitorMount]); + const calculator = new ExposedMekCalculator(entity); + expect(calculator.explosive(ppcMount)).toBeFalse(); + + entity.linkEquipment(capacitorMount, ppcMount); + expect(calculator.explosive(ppcMount)).toBeTrue(); + }); + + it('applies MegaMek switched-arc turret semantics to superheavy vehicles', () => { + class ExposedVehicleCalculator extends CombatVehicleBVCalculator { + switchedRear(mounted: EntityMountedEquipment): boolean { + this.switchRearAndFront = true; + return this.isNominalRear(mounted); + } + } + const weapon = new WeaponEquipment({ + id: 'test-weapon', name: 'Test Weapon', type: 'weapon', weapon: { ammoType: 'NA' }, + }); + const ordinary = new TestTankEntity(); + const ordinaryCalculator = new ExposedVehicleCalculator(ordinary); + expect(ordinaryCalculator.switchedRear(mount(weapon, 'Turret'))).toBeFalse(); + + const superheavy = new TestTankEntity(); + superheavy.setTonnage(200); + const superheavyCalculator = new ExposedVehicleCalculator(superheavy); + expect(superheavyCalculator.switchedRear(mount(weapon, 'Turret'))).toBeTrue(); + expect(superheavyCalculator.switchedRear(mount(weapon, 'Rear Left'))).toBeFalse(); + expect(superheavyCalculator.switchedRear(mount(weapon, 'Rear'))).toBeFalse(); + }); + + it('does not grant vehicle stealth TMM when movement is zero', () => { + const entity = new TestTankEntity(); + const stealth = new ArmorEquipment({ + id: 'vehicle-stealth', name: 'Vehicle Stealth', type: 'armor', + armor: { type: 'STEALTH_VEHICLE' }, + }); + entity.armorValues.set(new Map([['Front', { front: 10, rear: 0 }]])); + entity.setUniformArmor(new MountedArmor({ armor: stealth, techBase: 'IS' })); + + const result = calculateBattleValueDetails(entity); + const factor = findDetail(result.details, 'Defensive Factor'); + expect(factor?.calculation).toContain('x 1'); + }); + it('applies arm AES to offensive club equipment', () => { class ExposedMekCalculator extends MekBVCalculator { modifier(item: EntityMountedEquipment): number { return this.offensiveEquipmentModifier(item); } @@ -578,8 +677,11 @@ describe('structured battle value details', () => { id: 'split-hvac', name: 'Split HVAC', type: 'weapon', stats: { explosive: true, criticalSlots: 4 }, flags: ['F_HVAC'], }); - const splitMount = mount(hvac, 'LT'); - splitMount.isSplit = true; + const splitMount = mount(hvac, 'LT') + .withAddedPlacement({ location: 'LT', slotIndex: 0 }) + .withAddedPlacement({ location: 'LT', slotIndex: 1 }) + .withAddedPlacement({ location: 'RT', slotIndex: 0 }) + .withAddedPlacement({ location: 'RT', slotIndex: 1 }); entity.setEquipment([splitMount]); const explosive = findDetail(calculateBattleValueDetails(entity).details, 'Explosive Equipment'); diff --git a/src/app/models/entity/utils/battle-value/bv-calculator.ts b/src/app/models/entity/utils/battle-value/bv-calculator.ts index 62201fa4f..dada52886 100644 --- a/src/app/models/entity/utils/battle-value/bv-calculator.ts +++ b/src/app/models/entity/utils/battle-value/bv-calculator.ts @@ -40,6 +40,15 @@ export class BVCalculator { constructor(readonly entity: BaseEntity) {} + protected isExplosive(mount: EntityMountedEquipment): boolean { + const equipment = mount.equipment; + if (!equipment) return false; + if (equipment instanceof WeaponEquipment && equipment.hasFlag('F_PPC')) { + return this.entity.getLinkingMount(mount)?.equipment?.hasFlag('F_PPC_CAPACITOR') === true; + } + return equipment.isExplosive(); + } + calculateBaseBV(): number { return this.calculate().base; } diff --git a/src/app/models/entity/utils/battle-value/family-calculators.ts b/src/app/models/entity/utils/battle-value/family-calculators.ts index 58c9ded39..4be35c23b 100644 --- a/src/app/models/entity/utils/battle-value/family-calculators.ts +++ b/src/app/models/entity/utils/battle-value/family-calculators.ts @@ -6,6 +6,7 @@ import { BattleArmorEntity } from '../../entities/infantry/battle-armor-entity'; import { InfantryEntity } from '../../entities/infantry/infantry-entity'; import { MekEntity } from '../../entities/mek/mek-entity'; import { ProtoMekEntity } from '../../entities/protomek/protomek-entity'; +import { VehicleEntity } from '../../entities/vehicle/vehicle-entity'; import { getMekLegLocations, isQuadMekConfig } from '../../types/mek'; import { BV_MOVEMENT_CALCULATION } from '../../types'; import { getPpcCapacitorBV } from '../equipment-bv'; @@ -144,9 +145,9 @@ export class MekBVCalculator extends HeatTrackingBVCalculator { for (const mount of this.entity.equipment()) { const equipment = mount.equipment; if (!mount.armored || !equipment || equipment.hasFlag('F_PPC_CAPACITOR')) continue; - const placedSlots = mount.placements?.length; - const requiredSlots = mount.getCriticalSlotRequirement(this.entity); - let slots = placedSlots && placedSlots > 0 ? placedSlots + const placedSlots = mount.placedCriticalSlotCount; + const requiredSlots = mount.getNumCriticalSlots(this.entity); + let slots = placedSlots > 0 ? placedSlots : typeof requiredSlots === 'number' ? requiredSlots : 0; let value = mount.getBV(this.entity); if (equipment instanceof WeaponEquipment && equipment.hasFlag('F_PPC')) { @@ -218,13 +219,11 @@ export class MekBVCalculator extends HeatTrackingBVCalculator { } for (const mount of this.entity.equipment()) { const equipment = mount.equipment; - if (!equipment?.isExplosive() || mount.location === 'Unallocated' + if (!equipment || !this.isExplosive(mount) || mount.location === 'Unallocated' || equipment.hasFlag('F_BLUE_SHIELD') || !mount.getOccupiedLocations().some(location => this.hasExplosivePenalty(location))) continue; if (equipment instanceof AmmoEquipment && (mount.getAmmoShots() ?? 0) <= 0) continue; if (equipment instanceof WeaponEquipment) { - if (equipment.hasFlag('F_PPC') - && !this.entity.getLinkingMount(mount)?.equipment?.hasFlag('F_PPC_CAPACITOR')) continue; if (['AC_ROTARY', 'AC', 'AC_IMP', 'AC_PRIMITIVE', 'PAC', 'LAC'].includes(equipment.ammoType)) continue; } @@ -242,12 +241,12 @@ export class MekBVCalculator extends HeatTrackingBVCalculator { ]); const reducedAmmo = equipment instanceof AmmoEquipment && equipment.ammoType === 'COOLANT_POD'; const reduced = reducedWeapon || reducedMisc || reducedAmmo; - const placedSlots = mount.placements?.length; - const requiredSlots = mount.getCriticalSlotRequirement(this.entity); + const placedSlots = mount.placedCriticalSlotCount; + const requiredSlots = mount.getNumCriticalSlots(this.entity); const slots = equipment instanceof WeaponEquipment && equipment.hasFlag('F_HVAC') - && !mount.isSplit && !this.entity.isSuperHeavy() + && !mount.isSplitAcrossLocations && !this.entity.isSuperHeavy() ? 1 - : placedSlots && placedSlots > 0 ? placedSlots + : placedSlots > 0 ? placedSlots : typeof requiredSlots === 'number' ? requiredSlots : 1; const itemBefore = this.defensiveValue; const penalty = (reduced ? 1 : 15) * Math.max(1, slots); @@ -381,13 +380,21 @@ export class MekBVCalculator extends HeatTrackingBVCalculator { } protected override summarize(value: number): number { - let result = value * this.entity.mountedCockpit().bvMultiplier; + const cockpitType = this.entity.cockpitType(); + let modifier = 1; + if (['Small', 'Torso-Mounted', 'Small Command Console'].includes(cockpitType)) modifier = 0.95; + else if (this.has('F_DRONE_OPERATING_SYSTEM')) modifier = 0.95; + else if (cockpitType === 'Interface') modifier = 1.3; + else if (cockpitType === 'Virtual Reality Piloting Pod') modifier = 1.4; + let result = value * modifier; if (this.entity.hasRiscHeatSinkOverrideKit()) result *= 1.01; return result; } } export class CombatVehicleBVCalculator extends BVCalculator { + declare readonly entity: VehicleEntity; + protected override processTypeModifier(): void { const before = this.defensiveValue; let modifier = vehicleTypeModifier(this.entity.motiveType()); @@ -409,12 +416,15 @@ export class CombatVehicleBVCalculator extends BVCalculator { protected override processDefensiveFactor(): void { const airborne = this.entity.entityType === 'VTOL' || this.entity.entityType === 'SupportVTOL' || this.entity.motiveType() === 'WiGE'; - let running = targetMovementModifier(this.runMP, false, airborne); - let jumping = targetMovementModifier(this.jumpMP, true); + let running = this.runMP === 0 ? 0 : targetMovementModifier(this.runMP, false, airborne); + let jumping = this.jumpMP === 0 ? 0 : targetMovementModifier(this.jumpMP, true); const stealth = !this.entity.hasPatchworkArmor() && [...this.entity.armorByLocation().values()] .some(value => ['STEALTH', 'STEALTH_VEHICLE'].includes(value.armor.armorType)); - if (stealth) { running += 2; jumping += 2; } + if (stealth) { + if (this.runMP > 0) running += 2; + if (this.jumpMP > 0) jumping += 2; + } this.addReportLine('TMMs', `${running} (R), ${jumping} (J), 0 (U)`); const before = this.defensiveValue; const factor = 1 + Math.max(running, jumping) / 10; @@ -425,8 +435,12 @@ export class CombatVehicleBVCalculator extends BVCalculator { protected override frontWeapon(mount: EntityMountedEquipment): boolean { return mount.location === 'Front'; } protected override rearWeapon(mount: EntityMountedEquipment): boolean { return mount.location === 'Rear'; } protected override isNominalRear(mount: EntityMountedEquipment): boolean { - return !['Turret', 'Front Turret', 'Rear Turret'].includes(mount.location) - && super.isNominalRear(mount); + // MegaMek compares inherited Tank turret indices. On a superheavy those + // indices correspond to Rear Left and Rear, not its actual turret(s). + const excluded = this.entity.isSuperHeavy() + ? ['Rear Left', 'Rear'] + : ['Turret', 'Front Turret', 'Rear Turret']; + return !excluded.includes(mount.location) && super.isNominalRear(mount); } protected override processWeight(): void { const before = this.offensiveValue; @@ -464,13 +478,12 @@ export class AeroBVCalculator extends HeatTrackingBVCalculator { let otherExplosives = 0; for (const mount of this.entity.equipment()) { const equipment = mount.equipment; - if (!equipment?.isExplosive() || mount.location === 'Unallocated') continue; + if (!this.isExplosive(mount) || mount.location === 'Unallocated') continue; if (equipment instanceof AmmoEquipment) { if ((mount.getAmmoShots() ?? 0) > 0) ammoTypes.add(equipment.id); } else if (!(equipment instanceof WeaponEquipment) || !['AC_ROTARY', 'AC', 'AC_IMP', 'AC_PRIMITIVE', 'PAC', 'LAC'].includes(equipment.ammoType)) { - if (!(equipment instanceof WeaponEquipment && equipment.hasFlag('F_PPC') - && !this.entity.getLinkingMount(mount)?.equipment?.hasFlag('F_PPC_CAPACITOR'))) otherExplosives++; + otherExplosives++; } } this.defensiveValue -= ammoTypes.size * 15 + otherExplosives; diff --git a/src/app/models/entity/utils/cost/common.ts b/src/app/models/entity/utils/cost/common.ts index 9d3087d96..6c378ac1f 100644 --- a/src/app/models/entity/utils/cost/common.ts +++ b/src/app/models/entity/utils/cost/common.ts @@ -17,7 +17,7 @@ export function calculateArmorCost(entity: BaseEntity): number { const uniformArmor = entity.uniformArmor(); if (uniformArmor && !uniformArmor.armor.hasFlag('F_SUPPORT_VEE_BAR_ARMOR')) { const armor = uniformArmor.armor; - if (armor.cost === 'variable') throw new Error(`Unable to calculate armor cost for ${armor.id}`); + if (!armor.hasFixedCost()) throw new Error(`Unable to calculate armor cost for ${armor.id}`); const armorWeight = standardRound( entity.totalArmorPoints() / (16 * armor.pptMultiplier), entity, @@ -31,7 +31,7 @@ export function calculateArmorCost(entity: BaseEntity): number { const armorPoints = (points?.front ?? 0) + (points?.rear ?? 0); if (armorPoints <= 0) continue; const armor = mountedArmor.armor; - if (armor.cost === 'variable') throw new Error(`Unable to calculate armor cost for ${armor.id}`); + if (!armor.hasFixedCost()) throw new Error(`Unable to calculate armor cost for ${armor.id}`); if (armor.hasFlag('F_SUPPORT_VEE_BAR_ARMOR')) { total += armorPoints * armor.cost; } else { diff --git a/src/app/models/entity/utils/cost/equipment-pricing.ts b/src/app/models/entity/utils/cost/equipment-pricing.ts index 7ab2591d1..0c3f04e3e 100644 --- a/src/app/models/entity/utils/cost/equipment-pricing.ts +++ b/src/app/models/entity/utils/cost/equipment-pricing.ts @@ -25,9 +25,9 @@ export function getEquipmentCost( ): number | undefined { const equipment = mount.equipment; if (!equipment) return undefined; - if (equipment.cost !== 'variable') { + if (equipment.hasFixedCost()) { if (!(equipment instanceof WeaponEquipment) || !mount.armored) return equipment.cost; - const criticalSlots = equipment.getNumCriticalSlots(entity, mount.size ?? 1); + const criticalSlots = mount.getNumCriticalSlots(entity); return criticalSlots === undefined ? undefined : equipment.cost + (150000 * criticalSlots); @@ -159,6 +159,6 @@ export function getEquipmentCost( } if (cost === undefined || !mount.armored) return cost; - const criticalSlots = equipment.getNumCriticalSlots(entity, mount.size ?? 1); + const criticalSlots = mount.getNumCriticalSlots(entity); return criticalSlots === undefined ? undefined : cost + (150000 * criticalSlots); } diff --git a/src/app/models/entity/utils/cost/equipment-total.ts b/src/app/models/entity/utils/cost/equipment-total.ts index 9a33c0579..484d3eb91 100644 --- a/src/app/models/entity/utils/cost/equipment-total.ts +++ b/src/app/models/entity/utils/cost/equipment-total.ts @@ -1,6 +1,5 @@ import { AmmoEquipment, ArmorEquipment, WeaponEquipment } from '../../../equipment.model'; import type { BaseEntity } from '../../base-entity'; -import { getEquipmentCost } from './equipment-pricing'; import { amount } from './cost-report'; import type { EntityCostEntry } from './cost-report'; @@ -48,25 +47,10 @@ export function calculateMountedEquipmentCostBreakdown( // Java casts the complete mounted-item price to long once, after adding // support-vehicle infantry ammunition. addGrouped(equipment.name, Math.trunc(itemCost)); - if (mount.secondEquipment && !(mount.secondEquipment instanceof ArmorEquipment) - && !(ignoreAmmo && mount.secondEquipment instanceof AmmoEquipment - && mount.secondEquipment.ammoType !== 'COOLANT_POD')) { - const secondMount = mount.clone({ - equipmentId: mount.secondEquipmentId ?? mount.secondEquipment.id, - equipment: mount.secondEquipment, - secondEquipmentId: undefined, - secondEquipment: undefined, - }); - const secondCost = getEquipmentCost(entity, secondMount); - if (secondCost === undefined) { - throw new Error(`Unable to calculate variable cost for ${mount.secondEquipment.id}`); - } - addGrouped(mount.secondEquipment.name, Math.trunc(secondCost)); - } } if (entity.entityType === 'SmallCraft') { for (const equipment of entity.implicitSystemEquipment().filter(item => item.hasFlag('F_ECM'))) { - if (equipment.cost === 'variable') { + if (!equipment.hasFixedCost()) { throw new Error(`Unable to calculate variable cost for ${equipment.id}`); } addGrouped(equipment.name, Math.trunc(equipment.cost)); diff --git a/src/app/models/entity/utils/cost/fixed-wing-support.ts b/src/app/models/entity/utils/cost/fixed-wing-support.ts index cb099b0a2..c14c48c0f 100644 --- a/src/app/models/entity/utils/cost/fixed-wing-support.ts +++ b/src/app/models/entity/utils/cost/fixed-wing-support.ts @@ -95,7 +95,7 @@ function calculateFixedWingArmorCost(entity: FixedWingSupportEntity): number { const uniform = entity.uniformArmor(); if (uniform) { const armor = uniform.armor; - if (armor.cost === 'variable') throw new Error(`Unable to calculate armor cost for ${armor.id}`); + if (!armor.hasFixedCost()) throw new Error(`Unable to calculate armor cost for ${armor.id}`); if (armor.hasFlag('F_SUPPORT_VEE_BAR_ARMOR')) return entity.totalArmorPoints() * armor.cost; return standardRound(entity.totalArmorPoints() / (16 * armor.pptMultiplier), entity) * armor.cost; } @@ -103,7 +103,7 @@ function calculateFixedWingArmorCost(entity: FixedWingSupportEntity): number { let total = 0; for (const [location, mountedArmor] of entity.armorByLocation()) { const armor = mountedArmor.armor; - if (armor.cost === 'variable') throw new Error(`Unable to calculate armor cost for ${armor.id}`); + if (!armor.hasFixedCost()) throw new Error(`Unable to calculate armor cost for ${armor.id}`); const points = entity.armorValues().get(location); const armorPoints = (points?.front ?? 0) + (points?.rear ?? 0); total += armor.hasFlag('F_SUPPORT_VEE_BAR_ARMOR') diff --git a/src/app/models/entity/utils/cost/infantry.ts b/src/app/models/entity/utils/cost/infantry.ts index 559445d49..dfaba5b55 100644 --- a/src/app/models/entity/utils/cost/infantry.ts +++ b/src/app/models/entity/utils/cost/infantry.ts @@ -39,7 +39,7 @@ export function calculateBattleArmorCostReport( ? Math.trunc(getEquipmentCost(entity, mount) ?? 0) : 0), 0); const armor = entity.uniformArmor()?.armor; - if (armor?.cost === 'variable') throw new Error(`Unable to calculate armor cost for ${armor.id}`); + if (armor && !armor.hasFixedCost()) throw new Error(`Unable to calculate armor cost for ${armor.id}`); const armorPerTrooper = entity.armorValues().get('Squad')?.front ?? 0; const clanMultiplier = entity.techBase() === 'Clan' ? 1.1 : 1; const trainingCost = entity.techBase() === 'Clan' ? 200000 : 150000; diff --git a/src/app/models/entity/utils/cost/large-craft.ts b/src/app/models/entity/utils/cost/large-craft.ts index 21e86fd26..064b741e7 100644 --- a/src/app/models/entity/utils/cost/large-craft.ts +++ b/src/app/models/entity/utils/cost/large-craft.ts @@ -214,7 +214,7 @@ function calculateLargeCraftArmorCost(entity: CapitalCraft | DropShipEntity): nu const mountedArmor = entity.uniformArmor(); if (!mountedArmor) return 0; const armor = mountedArmor.armor; - if (armor.cost === 'variable') throw new Error(`Unable to calculate armor cost for ${armor.id}`); + if (!armor.hasFixedCost()) throw new Error(`Unable to calculate armor cost for ${armor.id}`); let rawArmor = entity.totalArmorPoints(); const primitive = 'driveCoreType' in entity diff --git a/src/app/models/entity/utils/cost/protomeks.ts b/src/app/models/entity/utils/cost/protomeks.ts index 8ae7fd2d7..c63b043a4 100644 --- a/src/app/models/entity/utils/cost/protomeks.ts +++ b/src/app/models/entity/utils/cost/protomeks.ts @@ -11,10 +11,11 @@ export function calculateProtoMekCostReport( ): EntityCostReport { const tonnage = entity.tonnage(); const engine = entity.mountedEngine(); - const armorCostPerPoint = entity.uniformArmor()?.armor.cost; - if (armorCostPerPoint === undefined || armorCostPerPoint === 'variable') { + const armor = entity.uniformArmor()?.armor; + if (!armor?.hasFixedCost()) { throw new Error('Unable to calculate ProtoMek armor cost'); } + const armorCostPerPoint = armor.cost; const energyWeaponHeat = entity.mountedWeapons() .filter(mount => mount.equipment.hasFlag('F_ENERGY')) .reduce((heat, mount) => heat + mount.equipment.heat, 0); diff --git a/src/app/models/entity/utils/cost/small-craft.ts b/src/app/models/entity/utils/cost/small-craft.ts index aa57d96f0..873f50d66 100644 --- a/src/app/models/entity/utils/cost/small-craft.ts +++ b/src/app/models/entity/utils/cost/small-craft.ts @@ -43,7 +43,7 @@ function calculateSmallCraftArmorCost(entity: SmallCraftEntity, primitive: boole const mountedArmor = entity.uniformArmor(); if (!mountedArmor) return 0; const armor = mountedArmor.armor; - if (armor.cost === 'variable') throw new Error(`Unable to calculate armor cost for ${armor.id}`); + if (!armor.hasFixedCost()) throw new Error(`Unable to calculate armor cost for ${armor.id}`); let rawArmor = entity.totalArmorPoints(); if (primitive) rawArmor = Math.ceil(rawArmor / 0.66); const thresholds = entity.motiveType() === 'Spheroid' ? SPHEROID_THRESHOLDS : AERODYNE_THRESHOLDS; diff --git a/src/app/models/entity/utils/equipment-bv.spec.ts b/src/app/models/entity/utils/equipment-bv.spec.ts index 069e5cb3d..d02d6e62d 100644 --- a/src/app/models/entity/utils/equipment-bv.spec.ts +++ b/src/app/models/entity/utils/equipment-bv.spec.ts @@ -49,6 +49,20 @@ describe('getEquipmentBV', () => { expect(mount('ram-plate', 'CT', variableEquipment('ram plate', ['F_RAM_PLATE'])).getBV(entity)).toBe(22); }); + it('uses BV-effective boosted movement for ram plate damage', () => { + const masc = fixedEquipment('masc', ['F_MASC']); + entity.setTonnage(50); + entity.originalWalkMP.set(5); + entity.setEquipment([ + mount('masc', 'LT', masc), + mount('supercharger', 'RT', masc), + ]); + + expect(entity.runMP()).toBe(8); + expect(entity.maxRunMP()).toBe(13); + expect(mount('ram-plate', 'CT', variableEquipment('ram plate', ['F_RAM_PLATE'])).getBV(entity)).toBe(35.2); + }); + it('passes through fixed BV', () => { expect(mount('fixed', 'CT', fixedEquipment('fixed', [], 25)).getBV(entity)).toBe(25); }); diff --git a/src/app/models/entity/utils/equipment-bv.ts b/src/app/models/entity/utils/equipment-bv.ts index 16d2dae47..7b913b5bf 100644 --- a/src/app/models/entity/utils/equipment-bv.ts +++ b/src/app/models/entity/utils/equipment-bv.ts @@ -26,7 +26,7 @@ export function getEquipmentBV(entity: BaseEntity, mount: EntityMountedEquipment return base * (equipment.hasFlag('S_PROTO_QMS') ? 2.5 : 1.25); } - if (equipment.bv !== 'variable') { + if (equipment.hasFixedBV()) { const hasRotorMastMount = entity.equipment().some(candidate => candidate.location === 'Rotor' && candidate.equipment?.hasFlag('F_MAST_MOUNT')); const receivesMastMountBonus = (entity.entityType === 'VTOL' || entity.entityType === 'SupportVTOL') @@ -60,7 +60,7 @@ export function getEquipmentBV(entity: BaseEntity, mount: EntityMountedEquipment .map(mount => mount.location) .filter(location => location === 'CT' || location === 'LT' || location === 'RT'), ).size; - const damage = Math.trunc(Math.trunc(tonnage * entity.runMP() * 0.1) / 2) + const damage = Math.trunc(Math.trunc(tonnage * entity.maxRunMP() * 0.1) / 2) + torsoSpikeLocations; bv = damage * 1.1; } else { diff --git a/src/app/models/entity/utils/equipment-engine-weight.spec.ts b/src/app/models/entity/utils/equipment-engine-weight.spec.ts new file mode 100644 index 000000000..68151063b --- /dev/null +++ b/src/app/models/entity/utils/equipment-engine-weight.spec.ts @@ -0,0 +1,9 @@ +import { roundToNearestHalfTon } from './equipment-engine-weight'; + +describe('equipment engine weight rounding', () => { + it('stabilizes nearest-half boundaries at kilogram precision', () => { + expect(roundToNearestHalfTon(20.25 - Number.EPSILON * 20.25)).toBe(20.5); + expect(roundToNearestHalfTon(20.2494)).toBe(20); + expect(roundToNearestHalfTon(20.2504)).toBe(20.5); + }); +}); \ No newline at end of file diff --git a/src/app/models/entity/utils/equipment-engine-weight.ts b/src/app/models/entity/utils/equipment-engine-weight.ts index 700ba0465..0df56e686 100644 --- a/src/app/models/entity/utils/equipment-engine-weight.ts +++ b/src/app/models/entity/utils/equipment-engine-weight.ts @@ -44,7 +44,12 @@ function getSupportVehicleEngineWeight( return entity.weightClass() === 'Small Support' ? Math.round(weight * 1000) / 1000 - : Math.round(weight * 2) / 2; + : roundToNearestHalfTon(weight); +} + +export function roundToNearestHalfTon(value: number): number { + const kilogramRounded = Math.round(value * 1000) / 1000; + return Math.round(kilogramRounded * 2) / 2; } function getBaseEngineValue( diff --git a/src/app/models/entity/utils/equipment-helpers.ts b/src/app/models/entity/utils/equipment-helpers.ts index b12e2253c..f0475e048 100644 --- a/src/app/models/entity/utils/equipment-helpers.ts +++ b/src/app/models/entity/utils/equipment-helpers.ts @@ -32,7 +32,7 @@ export function getNumCriticalSlots(entity: BaseEntity, eq: Equipment, size: num const isSuperHeavyMek = isMekEntity(entity) && entity.isSuperHeavy(); const isSuperHeavyEntity = isSuperHeavyMek || (isVehicleEntity(entity) && entity.isSuperHeavy()); - if (eq.critSlots !== "variable") { + if (eq.hasFixedCriticalSlots()) { const fixedSlots = eq.critSlots; if (isSuperHeavyEntity) { return Math.ceil(fixedSlots / 2); diff --git a/src/app/models/entity/utils/equipment-tonnage.ts b/src/app/models/entity/utils/equipment-tonnage.ts index 9ea27a55c..d1f55d6ed 100644 --- a/src/app/models/entity/utils/equipment-tonnage.ts +++ b/src/app/models/entity/utils/equipment-tonnage.ts @@ -27,7 +27,7 @@ export function getEquipmentTonnage( return equipment.rackSize * (equipment.ammoType === 'LRM_STREAK' ? 0.4 : 0.2); } } - if (equipment.tonnage !== 'variable') return equipment.tonnage; + if (equipment.hasFixedTonnage()) return equipment.tonnage; const tonnage = entity.tonnage(); if (equipment.hasFlag('F_JUMP_JET') || equipment.hasFlag('F_UMU')) { diff --git a/src/app/models/entity/utils/large-craft-control-tonnage.spec.ts b/src/app/models/entity/utils/large-craft-control-tonnage.spec.ts new file mode 100644 index 000000000..fe1c345e4 --- /dev/null +++ b/src/app/models/entity/utils/large-craft-control-tonnage.spec.ts @@ -0,0 +1,9 @@ +import { standardRound } from './large-craft-control-tonnage'; + +describe('large-craft control-system tonnage', () => { + it('stabilizes exact upward half-ton boundaries at kilogram precision', () => { + expect(standardRound(100 * 0.07)).toBe(7); + expect(standardRound(7.001)).toBe(7.5); + expect(standardRound(7.5)).toBe(7.5); + }); +}); \ No newline at end of file diff --git a/src/app/models/entity/utils/large-craft-control-tonnage.ts b/src/app/models/entity/utils/large-craft-control-tonnage.ts index bde1e502b..0756e888a 100644 --- a/src/app/models/entity/utils/large-craft-control-tonnage.ts +++ b/src/app/models/entity/utils/large-craft-control-tonnage.ts @@ -47,6 +47,7 @@ export function getCasparIITonnage(entity: BaseEntity, improved: boolean): numbe : standardRound(weight); } -function standardRound(tonnage: number): number { - return Math.ceil(tonnage * 2) / 2; +export function standardRound(tonnage: number): number { + const kilogramRounded = Math.round(tonnage * 1000) / 1000; + return Math.ceil(kilogramRounded * 2) / 2; } \ No newline at end of file diff --git a/src/app/models/entity/utils/weight/fighter-weight.ts b/src/app/models/entity/utils/weight/fighter-weight.ts index b75038f33..dc4a5f12a 100644 --- a/src/app/models/entity/utils/weight/fighter-weight.ts +++ b/src/app/models/entity/utils/weight/fighter-weight.ts @@ -1,6 +1,6 @@ import { EquipmentFlag } from '../../../equipment-flags.type'; import { AmmoEquipment, ArmorEquipment, isBombEquipment, MiscEquipment, StructureEquipment, WeaponEquipment } from '../../../equipment.model'; -import { isQuartersBay } from '../../bays/bay-definitions'; +import { getBayConstructionWeight, isQuartersBay } from '../../bays/bay-definitions'; import type { AeroEntity } from '../../entities/aero/aero-entity'; import type { ConvFighterEntity } from '../../entities/aero/conv-fighter-entity'; import { calculateHeatNeutralRequirement, calculatePowerAmplifierWeight } from '../cost/common'; @@ -70,7 +70,7 @@ export function calculateFighterWeightBreakdown(entity: AeroEntity): FighterWeig const carryingSpace = entity.transporters().reduce((total, transporter) => { if (transporter.kind === 'troop-space') return total + transporter.totalSpace; if (transporter.kind !== 'bay' || isQuartersBay(transporter)) return total; - return total + (transporter.constructionWeight ?? transporter.capacity); + return total + getBayConstructionWeight(transporter); }, 0); const exact = engine + controls + fuel + heatSinks + armor + vstol + miscellaneous + weapons + ammo + powerAmplifiers + carryingSpace; diff --git a/src/app/models/entity/utils/weight/fixed-wing-support-weight.spec.ts b/src/app/models/entity/utils/weight/fixed-wing-support-weight.spec.ts index 81d241998..56b8b5225 100644 --- a/src/app/models/entity/utils/weight/fixed-wing-support-weight.spec.ts +++ b/src/app/models/entity/utils/weight/fixed-wing-support-weight.spec.ts @@ -5,6 +5,36 @@ import { MountedEngine } from '../../components'; import { calculateFixedWingSupportWeightBreakdown } from './fixed-wing-support-weight'; describe('fixed-wing support construction mass', () => { + it('includes exported Omni base-chassis fire-control mass', () => { + const entity = new TestFixedWingSupportEntity(); + entity.baseChassisFireConWeight.set(5.5); + addTestEquipment(entity, createEquipment({ + id: 'Advanced Fire Control', name: 'Advanced Fire Control', type: 'misc', + flags: ['F_ADVANCED_FIRE_CONTROL'], stats: { tonnage: 'variable' }, + }), { location: 'Fuselage' }); + + const result = calculateFixedWingSupportWeightBreakdown(entity); + expect(result.fireControl).toBe(5.5); + expect(result.miscellaneous).toBe(0); + expect(result.exact).toBeGreaterThanOrEqual(5.5); + }); + + it('derives installed advanced fire-control mass from eligible weapons', () => { + const entity = new TestFixedWingSupportEntity(); + entity.setTonnage(20); + addTestEquipment(entity, createEquipment({ + id: 'Advanced Fire Control', name: 'Advanced Fire Control', type: 'misc', + flags: ['F_ADVANCED_FIRE_CONTROL'], stats: { tonnage: 'variable' }, + }), { location: 'Fuselage' }); + addTestEquipment(entity, new WeaponEquipment({ + id: 'Weapon', name: 'Weapon', type: 'weapon', stats: { tonnage: 5 }, + }), { location: 'Nose' }); + + const result = calculateFixedWingSupportWeightBreakdown(entity); + expect(result.fireControl).toBe(0.5); + expect(result.miscellaneous).toBe(0); + }); + it('uses the small fixed-wing chassis factor and kilogram rounding', () => { const entity = new TestFixedWingSupportEntity(); entity.setTonnage(4); diff --git a/src/app/models/entity/utils/weight/fixed-wing-support-weight.ts b/src/app/models/entity/utils/weight/fixed-wing-support-weight.ts index 0bf7236dd..bd0660962 100644 --- a/src/app/models/entity/utils/weight/fixed-wing-support-weight.ts +++ b/src/app/models/entity/utils/weight/fixed-wing-support-weight.ts @@ -1,6 +1,6 @@ import { EquipmentFlag } from '../../../equipment-flags.type'; import { AmmoEquipment, ArmorEquipment, isBombEquipment, MiscEquipment, StructureEquipment, WeaponEquipment } from '../../../equipment.model'; -import { isQuartersBay } from '../../bays/bay-definitions'; +import { getBayConstructionWeight, isQuartersBay } from '../../bays/bay-definitions'; import type { FixedWingSupportEntity } from '../../entities/aero/fixed-wing-support-entity'; import type { TechRating } from '../../types'; import { calculateHeatNeutralRequirement, calculatePowerAmplifierWeight } from '../cost/common'; @@ -30,6 +30,7 @@ const SYSTEM_FLAGS = [ export interface FixedWingSupportWeightBreakdown { readonly engine: number; readonly structure: number; readonly controls: number; + readonly fireControl: number; readonly heatSinks: number; readonly armor: number; readonly miscellaneous: number; readonly weapons: number; readonly ammo: number; readonly powerAmplifiers: number; readonly carryingSpace: number; readonly fuel: number; readonly exact: number; readonly rounded: number; @@ -60,6 +61,12 @@ export function calculateFixedWingSupportWeightBreakdown(entity: FixedWingSuppor }, 0)); const heatSinks = small ? 0 : calculateHeatNeutralRequirement(entity); const armor = calculateArmor(entity); + const fireControlMount = entity.equipment().find(mount => mount.equipment?.hasAnyFlag([ + 'F_BASIC_FIRE_CONTROL', 'F_ADVANCED_FIRE_CONTROL', + ])); + const fireControl = fireControlMount + ? requireTonnage(entity, fireControlMount) + : entity.baseChassisFireConWeight(); let miscellaneous = 0, weapons = 0, ammo = 0; for (const mount of entity.equipment()) { const equipment = mount.equipment; @@ -77,7 +84,7 @@ export function calculateFixedWingSupportWeightBreakdown(entity: FixedWingSuppor const carryingSpace = entity.transporters().reduce((total, transporter) => { if (transporter.kind === 'troop-space') return total + transporter.totalSpace; if (transporter.kind !== 'bay' || isQuartersBay(transporter)) return total; - return total + (transporter.constructionWeight ?? transporter.capacity); + return total + getBayConstructionWeight(transporter); }, 0); const prop = entity.equipment().some(mount => mount.equipment?.hasFlag('F_PROP')); const fuelFree = (prop || entity.motiveType() === 'Airship') @@ -88,9 +95,9 @@ export function calculateFixedWingSupportWeightBreakdown(entity: FixedWingSuppor if (kgPerFuelPoint && (prop || entity.motiveType() === 'Airship')) kgPerFuelPoint = Math.ceil(kgPerFuelPoint * 0.75); const fuelRaw = entity.fuel() * kgPerFuelPoint / 1000; const fuel = small ? ceilKg(fuelRaw) : ceilToHalfTon(fuelRaw); - const exact = engine + structure + controls + heatSinks + armor + miscellaneous + weapons + ammo + const exact = engine + structure + controls + heatSinks + armor + fireControl + miscellaneous + weapons + ammo + powerAmplifiers + carryingSpace + fuel; - return { engine, structure, controls, heatSinks, armor, miscellaneous, weapons, ammo, + return { engine, structure, controls, heatSinks, armor, fireControl, miscellaneous, weapons, ammo, powerAmplifiers, carryingSpace, fuel, exact, rounded: small ? ceilKg(exact) : ceilToHalfTon(exact) }; } diff --git a/src/app/models/entity/utils/weight/handheld-weapon-weight.spec.ts b/src/app/models/entity/utils/weight/handheld-weapon-weight.spec.ts index 276a7783d..6b52dd765 100644 --- a/src/app/models/entity/utils/weight/handheld-weapon-weight.spec.ts +++ b/src/app/models/entity/utils/weight/handheld-weapon-weight.spec.ts @@ -4,6 +4,15 @@ import { addTestEquipment } from '../../testing/test-mounted-equipment'; import { calculateHandheldWeaponWeightBreakdown } from './handheld-weapon-weight'; describe('handheld weapon construction mass', () => { + it('includes armor rounded up to the next half ton', () => { + const entity = new TestHandheldWeaponEntity(); + entity.armorValues.set(new Map([['Gun', { front: 8, rear: 0 }]])); + + const result = calculateHandheldWeaponWeightBreakdown(entity); + expect(result.armor).toBe(0.5); + expect(result.rounded).toBe(0.5); + }); + it('adds one ton of sinks per heat-neutral requirement', () => { const entity = new TestHandheldWeaponEntity(); addTestEquipment(entity, createEquipment({ diff --git a/src/app/models/entity/utils/weight/handheld-weapon-weight.ts b/src/app/models/entity/utils/weight/handheld-weapon-weight.ts index 3bdbd6158..5426ebd5b 100644 --- a/src/app/models/entity/utils/weight/handheld-weapon-weight.ts +++ b/src/app/models/entity/utils/weight/handheld-weapon-weight.ts @@ -4,6 +4,7 @@ import { calculateHeatNeutralRequirement } from '../cost/common'; import { ceilToHalfTon } from './weight-rounding'; export interface HandheldWeaponWeightBreakdown { + readonly armor: number; readonly heatSinks: number; readonly miscellaneous: number; readonly weapons: number; @@ -17,6 +18,7 @@ export function calculateHandheldWeaponEffectiveTonnage(entity: HandheldWeaponEn } export function calculateHandheldWeaponWeightBreakdown(entity: HandheldWeaponEntity): HandheldWeaponWeightBreakdown { + const armor = ceilToHalfTon(entity.totalArmorPoints() / 16); const heatSinks = calculateHeatNeutralRequirement(entity); let miscellaneous = 0; let weapons = 0; @@ -30,6 +32,6 @@ export function calculateHandheldWeaponWeightBreakdown(entity: HandheldWeaponEnt else if (equipment instanceof WeaponEquipment) weapons += tonnage; else if (equipment instanceof MiscEquipment) miscellaneous += tonnage; } - const exact = heatSinks + miscellaneous + weapons + ammo; - return { heatSinks, miscellaneous, weapons, ammo, exact, rounded: ceilToHalfTon(exact) }; + const exact = armor + heatSinks + miscellaneous + weapons + ammo; + return { armor, heatSinks, miscellaneous, weapons, ammo, exact, rounded: ceilToHalfTon(exact) }; } diff --git a/src/app/models/entity/utils/weight/mek-weight.ts b/src/app/models/entity/utils/weight/mek-weight.ts index 05e925a5a..1b4d6cc94 100644 --- a/src/app/models/entity/utils/weight/mek-weight.ts +++ b/src/app/models/entity/utils/weight/mek-weight.ts @@ -1,6 +1,6 @@ import { AmmoEquipment, ArmorEquipment, MiscEquipment, StructureEquipment, WeaponEquipment } from '../../../equipment.model'; import type { MekEntity } from '../../entities/mek/mek-entity'; -import { isQuartersBay } from '../../bays/bay-definitions'; +import { getBayConstructionWeight, isQuartersBay } from '../../bays/bay-definitions'; import { ceilToHalfTon, ceilToWholeTon } from './weight-rounding'; export interface MekWeightBreakdown { @@ -132,16 +132,7 @@ function calculateMekEquipmentWeight(entity: MekEntity): number { if (equipment instanceof ArmorEquipment || equipment instanceof StructureEquipment) return total; if (equipment instanceof MiscEquipment && equipment.hasAnyFlag([...SYSTEM_MISC_FLAGS])) return total; if (equipment instanceof AmmoEquipment && mount.allocation.kind === 'unallocated') return total; - let mountWeight = requireMountTonnage(entity, mount); - if (mount.secondEquipment) { - mountWeight += requireMountTonnage(entity, mount.clone({ - equipmentId: mount.secondEquipmentId ?? mount.secondEquipment.id, - equipment: mount.secondEquipment, - secondEquipmentId: undefined, - secondEquipment: undefined, - })); - } - return total + mountWeight; + return total + requireMountTonnage(entity, mount); }, 0); } @@ -165,7 +156,7 @@ function calculateMekCarryingSpaceWeight(entity: MekEntity): number { return entity.transporters().reduce((total, transporter) => { if (transporter.kind === 'troop-space') return total + transporter.totalSpace; if (transporter.kind !== 'bay' || isQuartersBay(transporter)) return total; - return total + (transporter.constructionWeight ?? transporter.capacity); + return total + getBayConstructionWeight(transporter); }, 0); } diff --git a/src/app/models/entity/utils/weight/small-craft-weight.spec.ts b/src/app/models/entity/utils/weight/small-craft-weight.spec.ts index e671fdfa2..608bad057 100644 --- a/src/app/models/entity/utils/weight/small-craft-weight.spec.ts +++ b/src/app/models/entity/utils/weight/small-craft-weight.spec.ts @@ -1,10 +1,36 @@ -import { ArmorEquipment, WeaponEquipment } from '../../../equipment.model'; +import { ArmorEquipment, createEquipment, WeaponEquipment } from '../../../equipment.model'; import { MountedArmor } from '../../components'; +import { createTestEquipmentRegistry } from '../../testing/test-equipment-registry'; +import { addTestEquipment } from '../../testing/test-mounted-equipment'; import { TestDropShipEntity, TestSmallCraftEntity } from '../../testing/test-entities'; import { EntityMountedEquipment } from '../../types/equipment'; import { calculateSmallCraftWeightBreakdown } from './small-craft-weight'; describe('Small Craft and DropShip construction mass', () => { + it('includes automatic military Small Craft ECM exactly once', () => { + const automaticEcm = createEquipment({ + id: 'ISSingle-Hex ECM', name: 'Single-Hex ECM', type: 'misc', flags: ['F_ECM'], + stats: { tonnage: 0.1 }, + }); + const weapon = new WeaponEquipment({ + id: 'Laser', name: 'Laser', type: 'weapon', stats: { tonnage: 1 }, + weapon: { damage: 10, ranges: [5, 10, 15, 20] }, + }); + const entity = new TestSmallCraftEntity(createTestEquipmentRegistry({ + [automaticEcm.id]: automaticEcm, + [weapon.id]: weapon, + })); + entity.designType.set('Military'); + addTestEquipment(entity, weapon, { location: 'Nose' }); + + expect(entity.implicitSystemEquipment()).toEqual([automaticEcm]); + expect(calculateSmallCraftWeightBreakdown(entity).miscellaneous).toBe(0.1); + + addTestEquipment(entity, automaticEcm, { location: 'Nose' }); + expect(entity.implicitSystemEquipment()).toEqual([]); + expect(calculateSmallCraftWeightBreakdown(entity).miscellaneous).toBe(0.1); + }); + it('uses Small Craft chassis engine and half-ton control formulas', () => { const entity = new TestSmallCraftEntity(); entity.setTonnage(200); diff --git a/src/app/models/entity/utils/weight/small-craft-weight.ts b/src/app/models/entity/utils/weight/small-craft-weight.ts index 9e8994e01..eed4e6730 100644 --- a/src/app/models/entity/utils/weight/small-craft-weight.ts +++ b/src/app/models/entity/utils/weight/small-craft-weight.ts @@ -72,6 +72,12 @@ export function calculateSmallCraftWeightBreakdown(entity: SmallCraftEntity): Sm miscellaneous += requireTonnage(entity, mount); } } + for (const equipment of entity.implicitSystemEquipment()) { + if (!equipment.hasFixedTonnage()) { + throw new Error(`Unable to calculate implicit equipment tonnage for ${equipment.id} on ${entity.displayName()}`); + } + miscellaneous += equipment.tonnage; + } let carryingSpace = 0, quarters = 0; for (const transporter of entity.transporters()) { if (transporter.kind === 'troop-space') carryingSpace += transporter.totalSpace; diff --git a/src/app/models/entity/utils/weight/support-vehicle-weight.spec.ts b/src/app/models/entity/utils/weight/support-vehicle-weight.spec.ts index f3ea0175e..3901668f0 100644 --- a/src/app/models/entity/utils/weight/support-vehicle-weight.spec.ts +++ b/src/app/models/entity/utils/weight/support-vehicle-weight.spec.ts @@ -45,4 +45,27 @@ describe('support vehicle construction mass', () => { expect(result.controls).toBe(0.075); expect(result.fuel).toBe(0.029); }); + + it('charges small-support infantry ammunition after the free first clip', () => { + const entity = new TestSupportTankEntity(); + entity.setTonnage(4); + addTestEquipment(entity, createEquipment({ + id: 'Infantry Rifle', name: 'Infantry Rifle', type: 'weapon', flags: ['F_INFANTRY'], + stats: { tonnage: 0.02 }, infantry: { ammoWeight: 0.004 }, + }), { location: 'Front', size: 3.5 }); + + const result = calculateSupportVehicleWeightBreakdown(entity); + expect(result.ammo).toBe(0.01); + }); + + it('does not charge the free first infantry-ammo clip', () => { + const entity = new TestSupportTankEntity(); + entity.setTonnage(4); + addTestEquipment(entity, createEquipment({ + id: 'Infantry Rifle', name: 'Infantry Rifle', type: 'weapon', flags: ['F_INFANTRY'], + stats: { tonnage: 0.02 }, infantry: { ammoWeight: 0.004 }, + }), { location: 'Front', size: 1 }); + + expect(calculateSupportVehicleWeightBreakdown(entity).ammo).toBe(0); + }); }); \ No newline at end of file diff --git a/src/app/models/entity/utils/weight/support-vehicle-weight.ts b/src/app/models/entity/utils/weight/support-vehicle-weight.ts index 5ea0ca2db..73ff927dc 100644 --- a/src/app/models/entity/utils/weight/support-vehicle-weight.ts +++ b/src/app/models/entity/utils/weight/support-vehicle-weight.ts @@ -1,6 +1,6 @@ import { EquipmentFlag } from '../../../equipment-flags.type'; import { AmmoEquipment, ArmorEquipment, MiscEquipment, StructureEquipment, WeaponEquipment } from '../../../equipment.model'; -import { isQuartersBay } from '../../bays/bay-definitions'; +import { getBayConstructionWeight, isQuartersBay } from '../../bays/bay-definitions'; import type { SupportVehicle } from '../../entities/support-vehicle'; import type { VehicleEntity } from '../../entities/vehicle/vehicle-entity'; import type { TechRating } from '../../types'; @@ -70,9 +70,12 @@ export function calculateSupportVehicleWeightBreakdown(entity: SupportVehicleEnt if (!equipment) throw new Error(`Unresolved equipment ${mount.equipmentId} on ${entity.displayName()}`); if (equipment instanceof ArmorEquipment || equipment instanceof StructureEquipment) continue; if (equipment instanceof AmmoEquipment) { - if (mount.location !== 'None') ammo += requireTonnage(entity, mount); + if (!small && mount.location !== 'None') ammo += requireTonnage(entity, mount); } else if (equipment instanceof WeaponEquipment) { weapons += requireTonnage(entity, mount); + if (small && equipment.isInfantryWeapon() && (mount.size ?? 1) > 1) { + ammo += ceilKg(((mount.size ?? 1) - 1) * equipment.infantry.ammoWeight); + } } else if (equipment instanceof MiscEquipment && !equipment.hasAnyFlag([...SYSTEM_MISC_FLAGS])) { miscellaneous += requireTonnage(entity, mount); } @@ -83,7 +86,7 @@ export function calculateSupportVehicleWeightBreakdown(entity: SupportVehicleEnt const carryingSpace = entity.transporters().reduce((total, transporter) => { if (transporter.kind === 'troop-space') return total + transporter.totalSpace; if (transporter.kind !== 'bay' || isQuartersBay(transporter)) return total; - return total + (transporter.constructionWeight ?? transporter.capacity); + return total + getBayConstructionWeight(transporter); }, 0); const exact = engine + structure + controls + heatSinks + armor + turret + dualTurret + miscellaneous + weapons + ammo + powerAmplifiers + carryingSpace + fuel; diff --git a/src/app/models/entity/utils/weight/vehicle-weight.ts b/src/app/models/entity/utils/weight/vehicle-weight.ts index e8778308f..4352fccc8 100644 --- a/src/app/models/entity/utils/weight/vehicle-weight.ts +++ b/src/app/models/entity/utils/weight/vehicle-weight.ts @@ -1,5 +1,5 @@ import { AmmoEquipment, ArmorEquipment, MiscEquipment, StructureEquipment, WeaponEquipment } from '../../../equipment.model'; -import { isQuartersBay } from '../../bays/bay-definitions'; +import { getBayConstructionWeight, isQuartersBay } from '../../bays/bay-definitions'; import type { VehicleEntity } from '../../entities/vehicle/vehicle-entity'; import { calculateHeatNeutralRequirement, calculatePowerAmplifierWeight } from '../cost/common'; import { getEquipmentEngineWeight } from '../equipment-engine-weight'; @@ -76,7 +76,7 @@ export function calculateVehicleWeightBreakdown(entity: VehicleEntity): VehicleW const carryingSpace = entity.transporters().reduce((total, transporter) => { if (transporter.kind === 'troop-space') return total + transporter.totalSpace; if (transporter.kind !== 'bay' || isQuartersBay(transporter)) return total; - return total + (transporter.constructionWeight ?? transporter.capacity); + return total + getBayConstructionWeight(transporter); }, entity.extraSeats() * 0.5); const exact = engine + structure + controls + heatSinks + armor + turret + dualTurret diff --git a/src/app/models/entity/writers/blk-mek-writer.ts b/src/app/models/entity/writers/blk-mek-writer.ts index 4c95dd560..9aeb3bcf0 100644 --- a/src/app/models/entity/writers/blk-mek-writer.ts +++ b/src/app/models/entity/writers/blk-mek-writer.ts @@ -35,6 +35,7 @@ import { MekEntity } from '../entities/mek/mek-entity'; import { QuadMekEntity } from '../entities/mek/quad-mek-entity'; import { CriticalSlotView, + formatCriticalSlotEquipment, } from '../types'; import { encodeBlkCockpitType, @@ -144,7 +145,8 @@ function slotToBlkString( ? getBlkEngineName(entity.mountedEngine()?.type()) : slot.systemType ?? '-1'; case 'equipment': - return encodeEquipmentLine(slot.mount); + return formatCriticalSlotEquipment(slot, (mount, isLast) => + encodeEquipmentLine(mount, { includeOmniPod: isLast && slot.omniPod })); } } diff --git a/src/app/models/entity/writers/blk-vehicle-writer.ts b/src/app/models/entity/writers/blk-vehicle-writer.ts index 18f797cec..f18f1c770 100644 --- a/src/app/models/entity/writers/blk-vehicle-writer.ts +++ b/src/app/models/entity/writers/blk-vehicle-writer.ts @@ -125,13 +125,13 @@ export function writeBlkVehicle(entity: VehicleEntity): string { } w.addBlock('armor', ...base); } else { - // Tank: Front, Right, Left, Rear[, Turret[, Rear Turret]] + // Tank: Front, Right, Left, Rear[, Turret] or Rear Turret, Front Turret const base: number[] = VEHICLE_ARMOR_LOCS.slice(0, 4).map(loc => armorMap.get(loc)?.front ?? 0); - if (entity.hasTurret()) { - base.push(armorMap.get('Turret')?.front ?? 0); - } if (entity.hasDualTurret()) { base.push(armorMap.get('Rear Turret')?.front ?? 0); + base.push(armorMap.get('Front Turret')?.front ?? 0); + } else if (entity.hasTurret()) { + base.push(armorMap.get('Turret')?.front ?? 0); } w.addBlock('armor', ...base); } diff --git a/src/app/models/entity/writers/equipment-encoder.ts b/src/app/models/entity/writers/equipment-encoder.ts index f3ea24031..9ce02fbf4 100644 --- a/src/app/models/entity/writers/equipment-encoder.ts +++ b/src/app/models/entity/writers/equipment-encoder.ts @@ -48,6 +48,9 @@ export interface EncodeEquipmentOptions { /** Marks this mount as the first member of a serialized weapon bay. */ startsWeaponBay?: boolean; + + /** Emits the mount's OmniPod suffix. Defaults to true. */ + includeOmniPod?: boolean; } /** @@ -99,7 +102,7 @@ export function encodeEquipmentLine(mount: EntityMountedEquipment, options?: Enc } // OmniPod suffix - if (mount.omniPodMounted) { + if (mount.omniPodMounted && (options?.includeOmniPod ?? true)) { name += ':OMNI'; } diff --git a/src/app/models/entity/writers/mtf-writer.ts b/src/app/models/entity/writers/mtf-writer.ts index 2e5bc1f75..df9ff4769 100644 --- a/src/app/models/entity/writers/mtf-writer.ts +++ b/src/app/models/entity/writers/mtf-writer.ts @@ -39,6 +39,8 @@ import { QuadVeeEntity } from '../entities/mek/quad-vee-entity'; import { LamEntity } from '../entities/mek/lam-entity'; import { CriticalSlotView, + EntityMountedEquipment, + formatCriticalSlotEquipment, MEK_SLOTS_PER_LOCATION, MekLocation, @@ -395,13 +397,20 @@ function writeFluff(entity: MekEntity, lines: string[]): void { function formatEquipmentSlot( slot: Extract, ): string { - const mount = slot.mount; + return formatCriticalSlotEquipment(slot, (mount, isLast) => + formatMountedEquipmentSlot(mount, isLast && slot.armored, isLast && slot.omniPod)); +} + +function formatMountedEquipmentSlot( + mount: EntityMountedEquipment, + armored: boolean, + omniPod: boolean, +): string { let name = mount.equipmentId; if (mount.rearMounted) name += ' (R)'; if (mount.turretMounted) name += ' (T)'; - // For split slots with secondEquipmentId, (OMNIPOD) goes on the second part - if (slot.omniPod && !mount.secondEquipmentId) name += ' (OMNIPOD)'; + if (omniPod) name += ' (OMNIPOD)'; if (mount.facing !== undefined) name += ` (${facingLabel(mount.facing)})`; if (mount.size !== undefined) { // Preserve decimal point: MegaMek writes SIZE:1.0, SIZE:2.0 etc. @@ -409,12 +418,7 @@ function formatEquipmentSlot( name += `:SIZE:${sizeStr}`; } // ARMORED goes after SIZE (MegaMek: "name:SIZE:1.0 (ARMORED)") - if (slot.armored) name += ' (ARMORED)'; - if (mount.secondEquipmentId) { - let second = mount.secondEquipmentId; - if (slot.omniPod) second += ' (OMNIPOD)'; - name += `|${second}`; - } + if (armored) name += ' (ARMORED)'; return name; } diff --git a/src/app/models/equipment.model.spec.ts b/src/app/models/equipment.model.spec.ts index f1de7577a..bd0663ed8 100644 --- a/src/app/models/equipment.model.spec.ts +++ b/src/app/models/equipment.model.spec.ts @@ -17,6 +17,32 @@ import { getStructureByName, getStructureByTypeId } from './entity/components'; import { EquipmentFlag } from './equipment-flags.type'; describe('equipment model', () => { + it('identifies fixed and variable equipment stats in one place', () => { + const fixed = createEquipment({ + id: 'fixed', name: 'Fixed', type: 'misc', + stats: { tonnage: 1, cost: 2, bv: 3, criticalSlots: 4 }, + }); + const variable = createEquipment({ + id: 'variable', name: 'Variable', type: 'misc', + stats: { + tonnage: 'variable', cost: 'variable', bv: 'variable', criticalSlots: 'variable', + }, + }); + + expect([ + fixed.hasFixedTonnage(), + fixed.hasFixedCost(), + fixed.hasFixedBV(), + fixed.hasFixedCriticalSlots(), + ]).toEqual([true, true, true, true]); + expect([ + variable.hasFixedTonnage(), + variable.hasFixedCost(), + variable.hasFixedBV(), + variable.hasFixedCriticalSlots(), + ]).toEqual([false, false, false, false]); + }); + it('deserializes structure records as StructureEquipment', () => { const equipment = createEquipment({ id: 'IS Endo-Composite', diff --git a/src/app/models/equipment.model.ts b/src/app/models/equipment.model.ts index 1df5396e0..e5072afb4 100644 --- a/src/app/models/equipment.model.ts +++ b/src/app/models/equipment.model.ts @@ -486,6 +486,12 @@ export class Equipment { get cost(): number | "variable" { return this.stats.cost; } get bv(): number | "variable" { return this.stats.bv; } get critSlots(): number | "variable" { return this.stats.criticalSlots; } + hasFixedTonnage(): this is this & { readonly tonnage: number } { return typeof this.tonnage === 'number'; } + hasFixedCost(): this is this & { readonly cost: number } { return typeof this.cost === 'number'; } + hasFixedBV(): this is this & { readonly bv: number } { return typeof this.bv === 'number'; } + hasFixedCriticalSlots(): this is this & { readonly critSlots: number } { + return typeof this.critSlots === 'number'; + } get svSlots(): number { return this.stats.svSlots; } get tankSlots(): number { return this.stats.tankSlots; } get techBase(): EquipmentTechBase { return this.tech.base; } diff --git a/src/app/models/mounted-equipment.model.ts b/src/app/models/mounted-equipment.model.ts index 6669c4ab8..0cc3fdbd7 100644 --- a/src/app/models/mounted-equipment.model.ts +++ b/src/app/models/mounted-equipment.model.ts @@ -249,12 +249,9 @@ export class MountedEquipment { } getBV(): number { - const baseBV = this.equipment?.bv; - if (!baseBV) return 0; - if (baseBV === "variable") { - return -1; - } - return baseBV; + if (!this.equipment) return 0; + if (!this.equipment.hasFixedBV()) return -1; + return this.equipment.bv; } } diff --git a/src/app/models/rules/game-rules.ts b/src/app/models/rules/game-rules.ts index 4cf151b58..ea7c02d6e 100644 --- a/src/app/models/rules/game-rules.ts +++ b/src/app/models/rules/game-rules.ts @@ -236,7 +236,7 @@ export class TWGameRules extends CBTGameRules { !c.rear ); const multiplier = hasNonRearWeapon ? 1 : 0.5; - if (crit.eq.bv === 'variable') continue; + if (!crit.eq.hasFixedBV()) continue; totalSemiGuidedBV += Math.round(multiplier * crit.eq.bv); } } diff --git a/src/app/utils/rs-polyfill.util.ts b/src/app/utils/rs-polyfill.util.ts index fb6f2d1de..0efc6b4d1 100644 --- a/src/app/utils/rs-polyfill.util.ts +++ b/src/app/utils/rs-polyfill.util.ts @@ -69,9 +69,6 @@ export class RsPolyfillUtil { private static readonly CREW_STATE_BANNER_WIDTH = 64; private static readonly CREW_STATE_BANNER_HEIGHT = 10; private static readonly CREW_STATE_BANNER_FONT_SIZE = 8; - private static readonly LOC_CONDITION_BUTTON_WIDTH = 8; - private static readonly LOC_CONDITION_BUTTON_HEIGHT = 8; - private static readonly LOC_CONDITION_BUTTON_GAP = 2; private static readonly CRITICAL_LOCATION_IDS = [ diff --git a/src/app/utils/unit-component-metadata-builder.ts b/src/app/utils/unit-component-metadata-builder.ts index e61553ca3..31958564e 100644 --- a/src/app/utils/unit-component-metadata-builder.ts +++ b/src/app/utils/unit-component-metadata-builder.ts @@ -103,7 +103,7 @@ function skipMisc(entity: BaseEntity, mount: EntityMountedEquipment, equipment: function skipUnallocatedBattleArmorEquipment(entity: BaseEntity, mount: EntityMountedEquipment): boolean { if (!(entity instanceof BattleArmorEntity) || mount.isDWP) return false; - const slots = mount.equipment?.getNumCriticalSlots(entity, mount.size ?? 1) ?? 0; + const slots = mount.getNumCriticalSlots(entity) ?? 0; return slots > 0 && !mount.baMountLocation; } @@ -352,7 +352,7 @@ function locationAbbreviation(entity: BaseEntity, location: string): string { function criticals( equipment: Equipment, entity: BaseEntity, mount?: EntityMountedEquipment, ): string { - if (equipment.critSlots === 'variable' && (entity instanceof MekEntity || entity.isSupportVehicle())) return 'V'; + if (!equipment.hasFixedCriticalSlots() && (entity instanceof MekEntity || entity.isSupportVehicle())) return 'V'; const slots = equipment.getNumCriticalSlots(entity, mount?.size ?? 1) ?? 0; if (entity.entityType === 'ProtoMek') return String(slots > 0 ? 1 : 0); return String(slots); diff --git a/src/styles.scss b/src/styles.scss index 1c8dd87d4..fdce75a7b 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -2048,6 +2048,10 @@ hr.divider { fill: #fff !important; } } + + &.read-only .edit-only { + display: none; + } .locConditionButton, .locationConditionControl {