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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions scripts/load-single-unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,6 @@ function main() {
console.log(` location=${locations} size=${mount.size ?? 1} tonnage=${tonnage === undefined ? '<unresolved>' : formatDiagnosticNumber(tonnage)} rear=${mount.rearMounted} omni=${mount.omniPodMounted}`);
console.log(` cost=${cost === undefined ? '<variable/unresolved>' : 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)}`);
Expand All @@ -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) ──
Expand Down Expand Up @@ -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 : ''}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
17 changes: 17 additions & 0 deletions src/app/components/page-viewer/svg-interaction.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<g class="unitConditionButton"></g>';
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 = '<g class="critSlot" loc="CT" slot="0" uid="CLActiveProbe@CT#0" hittable="1"><text>Active Probe</text></g>';
Expand Down
8 changes: 8 additions & 0 deletions src/app/components/page-viewer/svg-interaction.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down
12 changes: 4 additions & 8 deletions src/app/models/cbt-force-unit.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand All @@ -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;
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/models/entity/base-entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions src/app/models/entity/bays/bay-definitions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
87 changes: 66 additions & 21 deletions src/app/models/entity/entities/mek/mek-entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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<string, EntityMountedEquipment[]>();
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({
Expand All @@ -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;
});

Expand Down Expand Up @@ -958,6 +968,41 @@ export abstract class MekEntity extends BaseEntity {
}
}

private equipmentCriticalSlot(
mount: EntityMountedEquipment,
): Extract<CriticalSlotView, { type: 'equipment' }> {
return {
type: 'equipment', mounts: [mount],
armored: mount.armored,
omniPod: mount.omniPodMounted,
};
}

private appendEquipmentToCriticalSlot(
existing: Extract<CriticalSlotView, { type: 'equipment' }>,
incoming: EntityMountedEquipment,
): Extract<CriticalSlotView, { type: 'equipment' }> | 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) {
Expand Down
3 changes: 2 additions & 1 deletion src/app/models/entity/parsers/blk-codec.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 3 additions & 0 deletions src/app/models/entity/parsers/blk-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
9 changes: 9 additions & 0 deletions src/app/models/entity/parsers/blk-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading